diff --git a/.github/scripts/compare-coverage.js b/.github/scripts/compare-coverage.js index 31e861a6..5e120336 100644 --- a/.github/scripts/compare-coverage.js +++ b/.github/scripts/compare-coverage.js @@ -56,10 +56,10 @@ export function processReports(pkg, headDir, baseDir) { let delta = categories.branches.delta; - if (delta < 0) { + if (delta > 0) { delta = `+${delta}% 🟢`; - } else if (delta > 0) { - delta = `-${delta}% 🔴`; + } else if (delta < 0) { + delta = `${delta}% 🔴`; } else if (delta === 0) { delta = `0% ⚪️`; } diff --git a/.github/scripts/process-mutation.js b/.github/scripts/process-mutation.js index 4c043125..26677aa4 100644 --- a/.github/scripts/process-mutation.js +++ b/.github/scripts/process-mutation.js @@ -53,6 +53,10 @@ function processMutationReport(artitfactName, reportPath, changedFilesPath, prNu const metrics = getMetrics(obj); + if (metrics.total === 0) { + return `| ${packageName} | No mutants found. | |`; + } + const encodedPackageName = encodeURIComponent(packageName); const dashboardUrl = `[Dashboard](https://dashboard.stryker-mutator.io/reports/github.com/editor-js/document-model/PR-${prNumber}?module=${encodedPackageName})`; diff --git a/packages/core/src/api/SelectionAPI.spec.ts b/packages/core/src/api/SelectionAPI.spec.ts index e01f09ab..e3b0037e 100644 --- a/packages/core/src/api/SelectionAPI.spec.ts +++ b/packages/core/src/api/SelectionAPI.spec.ts @@ -5,7 +5,7 @@ import type { CoreConfigValidated } from '@editorjs/sdk'; // Mock dependencies before importing the module under test jest.unstable_mockModule('../components/SelectionManager', () => ({ SelectionManager: jest.fn(() => ({ - applyInlineToolForCurrentSelection: jest.fn(), + applyInlineTool: jest.fn(), })), })); @@ -39,7 +39,10 @@ describe('SelectionAPI', () => { }); expect(createInlineToolName).toHaveBeenCalledWith('bold'); - expect(selectionManager.applyInlineToolForCurrentSelection).toHaveBeenCalledWith('inline:bold', { level: 1 }); + expect(selectionManager.applyInlineTool).toHaveBeenCalledWith({ + toolName: 'inline:bold', + data: { level: 1 }, + }); }); }); }); diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index 7c968e40..54fc86ce 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; import { inject, injectable } from 'inversify'; import { SelectionManager } from '../components/SelectionManager.js'; -import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType } from '@editorjs/model'; +import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType, Index } from '@editorjs/model'; import { CoreConfigValidated } from '@editorjs/sdk'; import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; import { TOKENS } from '../tokens.js'; @@ -33,14 +33,33 @@ export class SelectionAPI implements SelectionApiInterface { this.#config = config; } + /** + * Returns caret index for current user (or null) + * @returns Index of the caret for the current user or null + */ + public get caretIndex(): Index | null { + return this.#selectionManager.currentSelection; + } + /** * Applies passed inline tool to the current selection * @param params - methods parameters * @param params.tool - Inline Tool name from the config to apply on the current selection * @param [params.data] - Inline Tool data to apply to the current selection (e.g. link data) + * @param [params.caretIndex] - index where to apply the tool, by default equals current selection + * @param [params.action] - by default, method will flip the formatting. You can choose a specific action with this parameter + * @param [params.keepSelection] - if false, selection will be collapsed to the right. If true, selection will be restored to the caretIndex. True by default + * @param [params.userId] - id of a user to attribute the change to */ - public applyInlineTool({ tool, data }: Parameters[0]): void { - this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(tool), data); + public applyInlineTool({ tool, data, caretIndex, userId, action, keepSelection }: Parameters[0]): void { + this.#selectionManager.applyInlineTool({ + toolName: createInlineToolName(tool), + data, + userId, + caretIndex, + action, + keepSelection, + }); } /** diff --git a/packages/core/src/components/SelectionManager.spec.ts b/packages/core/src/components/SelectionManager.spec.ts index 4f718074..ff61f984 100644 --- a/packages/core/src/components/SelectionManager.spec.ts +++ b/packages/core/src/components/SelectionManager.spec.ts @@ -3,7 +3,7 @@ import { jest } from '@jest/globals'; import type { CoreConfigValidated } from '@editorjs/sdk'; // @ts-expect-error - TS don't import types via import() so have to import them here as well -import type { CaretManagerEvents, InlineFragment, InlineToolName, EventType, Index } from '@editorjs/model'; +import type { CaretManagerEvents, InlineFragment, InlineToolName, EventType, Index, FormattingAction } from '@editorjs/model'; // Register ESM mocks before importing the module under test jest.unstable_mockModule('@editorjs/model', () => { @@ -27,10 +27,17 @@ jest.unstable_mockModule('@editorjs/model', () => { serialized: { blocks: [] }, })); + class IndexBuilderMock { + public from = jest.fn(() => this); + public addTextRange = jest.fn(() => this); + public build = jest.fn(() => ({ getTextSegments: jest.fn(() => []) })); + } + return { EditorJSModel, CaretManagerCaretUpdatedEvent: caretManagerCaretUpdatedEvent, Index: { parse: jest.fn() }, + IndexBuilder: IndexBuilderMock, EventType: eventType, createInlineToolData: (data: Record) => data, createInlineToolName: (name: string) => name, @@ -111,7 +118,7 @@ describe('SelectionManager', () => { expect(SelectionChangedCoreEvent).toHaveBeenCalledWith(expect.objectContaining({ index: null, fragments: [], - availableInlineTools: expect.any(Map), + availableInlineTools: expect.any(Array), })); expect(eventBus.dispatchEvent).toHaveBeenCalled(); }); @@ -205,18 +212,18 @@ describe('SelectionManager', () => { caretEventsListener(event); - const callArg = (SelectionChangedCoreEvent as jest.MockedClass).mock.calls[0][0] as { availableInlineTools: Map }; + const callArg = ((SelectionChangedCoreEvent as jest.MockedClass).mock.calls[0][0] as unknown) as { availableInlineTools: unknown[] }; - expect(callArg.availableInlineTools.has('italic')).toBe(true); + expect(callArg.availableInlineTools).toContain(facadeMock); }); }); - describe('.applyInlineToolForCurrentSelection()', () => { + describe('.applyInlineTool()', () => { it('should throw when caret is not set', () => { jest.spyOn(model, 'getCaret').mockReturnValue(undefined); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); }).toThrow(); }); @@ -224,7 +231,7 @@ describe('SelectionManager', () => { jest.spyOn(model, 'getCaret').mockReturnValue({ index: null } as unknown as ReturnType); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); }).toThrow(); }); @@ -234,7 +241,7 @@ describe('SelectionManager', () => { jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); }).toThrow(); }); @@ -249,8 +256,8 @@ describe('SelectionManager', () => { (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map(); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); - }).toThrow('SelectionManager[applyInlineToolForCurrentSelection]: tool bold is not attached'); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); + }).toThrow('SelectionManager[applyInlineTool]: tool bold is not attached'); }); it('should call model.format when tool getFormattingOptions returns Format action', () => { @@ -269,10 +276,14 @@ describe('SelectionManager', () => { textRange: [0, 3] }]), }; - jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + jest.spyOn(model, 'getCaret') + .mockReturnValue({ + index: indexMock, + update: jest.fn(), + } as unknown as ReturnType); jest.spyOn(model, 'getFragments').mockReturnValue([]); - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); expect(mockFormat).toHaveBeenCalled(); }); @@ -293,10 +304,14 @@ describe('SelectionManager', () => { textRange: [0, 3] }]), }; - jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + jest.spyOn(model, 'getCaret') + .mockReturnValue({ + index: indexMock, + update: jest.fn(), + } as unknown as ReturnType); jest.spyOn(model, 'getFragments').mockReturnValue([]); - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); expect(mockUnformat).toHaveBeenCalled(); }); @@ -315,7 +330,7 @@ describe('SelectionManager', () => { jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); }).toThrow('TextRange of the index should be defined'); }); @@ -333,7 +348,7 @@ describe('SelectionManager', () => { jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); }).toThrow('BlockIndex should be defined'); }); @@ -351,8 +366,104 @@ describe('SelectionManager', () => { jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); expect(() => { - selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName }); }).toThrow('DataKey of the index should be defined'); }); + + it('should collapse selection to end when keepSelection is false', () => { + const caretUpdateMock = jest.fn(); + const toolMock = { + getFormattingOptions: jest.fn(() => ({ action: 'format', + range: [0, 3] })), + }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret') + .mockReturnValue({ + index: indexMock, + update: caretUpdateMock, + } as unknown as ReturnType); + jest.spyOn(model, 'getFragments').mockReturnValue([]); + + selectionManager.applyInlineTool({ + toolName: 'bold' as InlineToolName, + keepSelection: false, + }); + + expect(caretUpdateMock).toHaveBeenCalledWith(expect.objectContaining({ getTextSegments: expect.any(Function) })); + }); + + it('should override tool action when action parameter is provided', () => { + const mockUnformat = jest.spyOn(model, 'unformat').mockImplementation(() => undefined); + const toolMock = { + getFormattingOptions: jest.fn(() => ({ action: 'format', + range: [0, 3] })), + }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret') + .mockReturnValue({ + index: indexMock, + update: jest.fn(), + } as unknown as ReturnType); + jest.spyOn(model, 'getFragments').mockReturnValue([]); + + // Tool suggests Format, but we override with Unformat + selectionManager.applyInlineTool({ + toolName: 'bold' as InlineToolName, + action: 'unformat' as FormattingAction, + }); + + expect(mockUnformat).toHaveBeenCalled(); + }); + + it('should not update caret for non-current userId', () => { + const caretUpdateMock = jest.fn(); + const toolMock = { + getFormattingOptions: jest.fn(() => ({ action: 'format', + range: [0, 3] })), + }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret') + .mockReturnValue({ + index: indexMock, + update: caretUpdateMock, + } as unknown as ReturnType); + jest.spyOn(model, 'getFragments').mockReturnValue([]); + + // Apply tool with a different userId + selectionManager.applyInlineTool({ + toolName: 'bold' as InlineToolName, + userId: 'another-user', + }); + + // Caret should not be updated for non-current user + expect(caretUpdateMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 6f2c1ad5..f203472b 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -2,11 +2,11 @@ import 'reflect-metadata'; import { CaretManagerEvents, createInlineToolData, - FormattingAction, + FormattingAction, IndexBuilder, InlineFragment, InlineToolName } from '@editorjs/model'; -import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolName } from '@editorjs/model'; +import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel } from '@editorjs/model'; import { EventType } from '@editorjs/model'; import { EventBus, @@ -94,11 +94,10 @@ export class SelectionManager { /** * @todo implement filter by current BlockTool configuration */ - availableInlineTools: new Map( + availableInlineTools: Array.from( this.#toolsManager .inlineTools - .entries() - .map(([name, facade]) => [createInlineToolName(name), facade.create()]) + .values() ), fragments, })); @@ -109,30 +108,53 @@ export class SelectionManager { } /** - * Apply format with data formed in toolbar - * @param toolName - name of the inline tool, whose format would be applied - * @param data - fragment data for the current selection + * Returns index of current user's caret (selection) or null */ - public applyInlineToolForCurrentSelection(toolName: InlineToolName, data: InlineToolFormatData = {}): void { - /** - * @todo use inline tool data formed in toolbar - */ + public get currentSelection(): Readonly | null { const userCaret = this.#model.getCaret(this.#config.userId); - const index = userCaret?.index ?? null; + return userCaret?.index ?? null; + } - if (index === null) { - throw new IndexError('SelectionManager[applyInlineToolForCurrentSelection]: caret index is outside of the input'); + /** + * Apply format with data formed in toolbar + * @param params - method parameters, see comments to the param types + */ + public applyInlineTool({ + toolName, + data = {}, + userId = this.#config.userId, + caretIndex = this.currentSelection, + keepSelection = true, + action: actionOverride, + }: { + /** Name of the inline tool to apply */ + toolName: InlineToolName; + /** Inline tool formatting data */ + data?: InlineToolFormatData; + /** ID of the user applying the change */ + userId?: string | number; + /** Caret index to apply formatting for */ + caretIndex?: Readonly | null; + /** Optional action override for formatting/unformatting */ + action?: FormattingAction; + /** If true, Manager will restore the selection after applying the tool. True by default */ + keepSelection?: boolean; + }): void { + if (caretIndex === null) { + throw new IndexError('SelectionManager[applyInlineTool]: caret index is outside of the input'); } + const caret = this.#model.getCaret(userId); + /** * @todo do not store middle segments in the index, use only the first and last segments * Also, we need to sort inputs inside first/last block by document order to restore selection */ - const segments = index.getTextSegments(); + const segments = caretIndex.getTextSegments(); if (segments.length === 0) { - throw new IndexError('SelectionManager[applyInlineToolForCurrentSelection]: caret index is outside of the input'); + throw new IndexError('SelectionManager[applyInlineTool]: caret index is outside of the input'); } const tool = this.#toolsManager.inlineTools.get(toolName)?.create(); @@ -141,7 +163,7 @@ export class SelectionManager { * @todo think of config synchronisation. If remote user has some tools current user doesn't there's going to be mismatch in the data */ if (tool === undefined) { - throw new Error(`SelectionManager[applyInlineToolForCurrentSelection]: tool ${toolName} is not attached`); + throw new Error(`SelectionManager[applyInlineTool]: tool ${toolName} is not attached`); } for (const segment of segments) { @@ -165,16 +187,40 @@ export class SelectionManager { const { action, range } = tool.getFormattingOptions(textRange, fragments); - switch (action) { + switch (actionOverride ?? action) { case FormattingAction.Format: - this.#model.format(this.#config.userId, blockIndex, dataKey, toolName, ...range, createInlineToolData(data)); + this.#model.format(userId, blockIndex, dataKey, toolName, ...range, createInlineToolData(data)); break; case FormattingAction.Unformat: - this.#model.unformat(this.#config.userId, blockIndex, dataKey, toolName, ...range); + this.#model.unformat(userId, blockIndex, dataKey, toolName, ...range); break; } + + /** + * Keep selection param is applied only for the current user + */ + if (userId === this.#config.userId) { + if (keepSelection) { + caret?.update(caretIndex); + } else { + // For composite selections, don't try to add textRange since composite indices + // must not have root-level textRange. Only set textRange for single-segment selections. + const selectedSegments = caretIndex.getTextSegments(); + + if (selectedSegments.length === 1 && selectedSegments[0].textRange !== undefined) { + caret?.update( + new IndexBuilder() + .from(caretIndex) + .addTextRange([selectedSegments[0].textRange[1], selectedSegments[0].textRange[1]]) + .build() + ); + } else { + caret?.update(caretIndex); + } + } + } } }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f12401a..d88bba37 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ import { CollaborationManager } from '@editorjs/collaboration-manager'; import { type DocumentId, EditorJSModel, EventType } from '@editorjs/model'; +import type { Factory } from 'inversify'; import { Container } from 'inversify'; import { type BlockToolConstructor, @@ -42,11 +43,6 @@ export default class Core { */ #model: EditorJSModel; - /** - * Tools manager is responsible for creating tools - */ - #toolsManager: ToolsManager; - /** * Editor configuration */ @@ -66,8 +62,10 @@ export default class Core { * @param config - Editor configuration */ constructor(config: CoreConfig) { - this.#iocContainer = new Container({ autobind: true, - defaultScope: 'Singleton' }); + this.#iocContainer = new Container({ + autobind: true, + defaultScope: 'Singleton', + }); this.#plugins = new Container(); this.#validateConfig(config); @@ -92,7 +90,12 @@ export default class Core { this.#iocContainer.bind(EditorJSModel).toConstantValue(this.#model); - this.#toolsManager = this.#iocContainer.get(ToolsManager); + /** + * Bind EditorAPI factory so components can request the API avoiding circular dependencies + * ToolsManager is an example: it needs API to provide it to the Tools, but being a dependency to BlocksManager which is a dependency to API + */ + this.#iocContainer.bind>(TOKENS.EditorAPIFactory) + .toFactory(ctx => () => ctx.get(EditorAPI)); if (config.onModelUpdate !== undefined) { this.#model.addEventListener(EventType.Changed, () => { @@ -160,9 +163,6 @@ export default class Core { this.#initializeAdapter(); - this.#initializePlugins(); - await this.#initializeTools(); - /** * Need to initialize internal modules before plugins and tools * @todo think of how to remove this? @@ -174,6 +174,9 @@ export default class Core { this.#iocContainer.get(BlockRenderer); this.#iocContainer.get(UndoRedoManager); + this.#initializePlugins(); + await this.#initializeTools(); + this.#model.initializeDocument({ blocks }); const eventBus = this.#iocContainer.get(EventBus); @@ -185,14 +188,16 @@ export default class Core { } /** - * Initalizes loaded tools + * Initializes loaded tools */ async #initializeTools(): Promise { const blockTools = this.#plugins.getAll<[BlockToolConstructor, ToolStaticOptions | undefined]>(ToolType.Block); const inlineTools = this.#plugins.getAll<[InlineToolConstructor, ToolStaticOptions | undefined]>(ToolType.Inline); const blockTunes = this.#plugins.getAll<[BlockTuneConstructor, ToolStaticOptions | undefined]>(ToolType.Tune); - return this.#toolsManager.prepareTools([...blockTools, ...inlineTools, ...blockTunes]); + const toolsManager = this.#iocContainer.get(ToolsManager); + + return toolsManager.prepareTools([...blockTools, ...inlineTools, ...blockTunes]); } /** @@ -214,11 +219,11 @@ export default class Core { */ #initializePlugin(plugin: EditorjsPluginConstructor): void { const eventBus = this.#iocContainer.get(EventBus); - const api = this.#iocContainer.get(EditorAPI); + const apiFactory = this.#iocContainer.get>(TOKENS.EditorAPIFactory) as () => EditorAPI; new plugin({ config: this.#config, - api, + api: apiFactory(), eventBus, }); } @@ -232,11 +237,11 @@ export default class Core { this.#iocContainer.bind(TOKENS.Adapter) .toDynamicValue((ctx) => { const eventBus = ctx.get(EventBus); - const api = ctx.get(EditorAPI); + const apiFactory = ctx.get>(TOKENS.EditorAPIFactory) as () => EditorAPI; return new Adapter({ config: this.#config, - api, + api: apiFactory(), eventBus, }); }) diff --git a/packages/core/src/plugins/ShortcutsPlugin.ts b/packages/core/src/plugins/ShortcutsPlugin.ts index df565fcf..e7c6269c 100644 --- a/packages/core/src/plugins/ShortcutsPlugin.ts +++ b/packages/core/src/plugins/ShortcutsPlugin.ts @@ -18,7 +18,7 @@ import { /** * Subscribes to tool-loaded events and registers keyboard shortcuts from merged tool `options` * (`shortcut` for inline tools; `shortcuts` map reserved for block tools / render overrides). - * Applies formatting via `api.selection.applyInlineToolForCurrentSelection`. + * Applies formatting via `api.selection.applyInlineTool`. */ export class ShortcutsPlugin implements EditorjsPlugin { /** diff --git a/packages/core/src/tokens.ts b/packages/core/src/tokens.ts index 13fee1b9..5860adbf 100644 --- a/packages/core/src/tokens.ts +++ b/packages/core/src/tokens.ts @@ -12,4 +12,8 @@ export const TOKENS = { * Adapter token */ Adapter: Symbol.for('Adapter'), + /** + * Editor API token + */ + EditorAPIFactory: Symbol.for('Factory'), } as const; diff --git a/packages/core/src/tools/ToolsFactory.ts b/packages/core/src/tools/ToolsFactory.ts index 36b3790e..59eacbf1 100644 --- a/packages/core/src/tools/ToolsFactory.ts +++ b/packages/core/src/tools/ToolsFactory.ts @@ -57,8 +57,7 @@ export class ToolsFactory { constructor( config: Record, editorConfig: EditorConfig, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - api: any + api: EditorAPI ) { this.#api = api; this.#config = config; @@ -98,8 +97,7 @@ export class ToolsFactory { name, constructable, useToolOptions, - api: {}, - // api: this.api.getMethodsForTool(name, isTune), + api: this.#api, isDefault: name === this.#editorConfig.defaultBlock, defaultPlaceholder: this.#editorConfig.placeholder, /** diff --git a/packages/core/src/tools/ToolsManager.ts b/packages/core/src/tools/ToolsManager.ts index a818d811..3026fdbb 100644 --- a/packages/core/src/tools/ToolsManager.ts +++ b/packages/core/src/tools/ToolsManager.ts @@ -15,8 +15,10 @@ import { ToolsCollection, EventBus, type ToolConstructable, - type ToolStaticOptions + type ToolStaticOptions, + CoreConfigValidated } from '@editorjs/sdk'; +import type { EditorAPI } from '../api'; /** * Works with tools @@ -25,9 +27,14 @@ import { @injectable() export default class ToolsManager { /** - * ToolsFactory instance + * EditorAPI factory function */ - #factory: ToolsFactory; + #apiFactory: () => EditorAPI; + + /** + * Editor's config + */ + #editorConfig: CoreConfigValidated; /** * Processed tools config @@ -87,19 +94,20 @@ export default class ToolsManager { /** * @param editorConfig - EditorConfig object - * @param editorConfig.tools - Tools configuration passed by user + * @param apiFactory - Editor API factory function * @param eventBus - EventBus instance to exchange events between components */ constructor( - @inject(TOKENS.EditorConfig) editorConfig: EditorConfig, + @inject(TOKENS.EditorConfig) editorConfig: CoreConfigValidated, + @inject(TOKENS.EditorAPIFactory) apiFactory: () => EditorAPI, eventBus: EventBus ) { this.#config = this.#prepareConfig(editorConfig.tools ?? {}); this.#eventBus = eventBus; + this.#apiFactory = apiFactory; + this.#editorConfig = editorConfig; this.#validateTools(); - - this.#factory = new ToolsFactory(this.#config, editorConfig, {}); } /** @@ -117,7 +125,9 @@ export default class ToolsManager { })); }; - this.#factory.setTools(tools); + const factory = new ToolsFactory(this.#config, this.#editorConfig, this.#apiFactory()); + + factory.setTools(tools); tools.forEach(([toolConstructor]) => { const toolName = toolConstructor.name; @@ -125,7 +135,7 @@ export default class ToolsManager { if (isFunction(toolConstructor.prepare)) { void promiseQueue.add(async () => { try { - const tool = this.#factory.get(toolName); + const tool = factory.get(toolName); /** * Merged plugin `config` only (static `options().config` + `use(Tool, options).config`), aligned with `BaseToolFacade.prepare`. @@ -162,11 +172,11 @@ export default class ToolsManager { } catch (e) { console.error(`Tool ${toolName} failed to prepare`, e); - this.#unavailableTools.set(toolName, this.#factory.get(toolName)); + this.#unavailableTools.set(toolName, factory.get(toolName)); } }); } else { - setToAvailableToolsCollection(toolName, this.#factory.get(toolName)); + setToAvailableToolsCollection(toolName, factory.get(toolName)); } }); diff --git a/packages/core/src/tools/internal/inline-tools/bold/index.ts b/packages/core/src/tools/internal/inline-tools/bold/index.ts index ba3ec7bf..5ef86f7a 100644 --- a/packages/core/src/tools/internal/inline-tools/bold/index.ts +++ b/packages/core/src/tools/internal/inline-tools/bold/index.ts @@ -1,9 +1,10 @@ -import type { ToolFormattingOptions, InlineTool, InlineToolConstructor } from '@editorjs/sdk'; +import type { ToolFormattingOptions, InlineTool, InlineToolConstructor, MenuConfig } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk'; import type { InlineFragment, TextRange } from '@editorjs/model'; import { FormattingAction } from '@editorjs/model'; import { IntersectType } from '@editorjs/model'; import { make } from '@editorjs/dom'; +import { IconBold } from '@codexteam/icons'; /** * Bold Tool @@ -44,6 +45,15 @@ export class BoldInlineTool implements InlineTool { */ public intersectType: IntersectType = IntersectType.Extend; + /** + * Returns inline toolbar configuration for the tool + */ + public getToolbarConfig(): MenuConfig { + return { + icon: IconBold, + }; + } + /** * Renders wrapper for tool without actual content * @returns Created html element diff --git a/packages/core/src/tools/internal/inline-tools/italic/index.ts b/packages/core/src/tools/internal/inline-tools/italic/index.ts index 00d848b5..04952ced 100644 --- a/packages/core/src/tools/internal/inline-tools/italic/index.ts +++ b/packages/core/src/tools/internal/inline-tools/italic/index.ts @@ -1,9 +1,10 @@ -import type { ToolFormattingOptions, InlineTool, InlineToolConstructor } from '@editorjs/sdk'; +import type { ToolFormattingOptions, InlineTool, InlineToolConstructor, MenuConfig } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk'; import type { InlineFragment, TextRange } from '@editorjs/model'; import { FormattingAction } from '@editorjs/model'; import { IntersectType } from '@editorjs/model'; import { make } from '@editorjs/dom'; +import { IconItalic } from '@codexteam/icons'; /** * Italic Tool @@ -44,6 +45,15 @@ export class ItalicInlineTool implements InlineTool { */ public intersectType: IntersectType = IntersectType.Extend; + /** + * Returns inline toolbar configuration for the tool + */ + public getToolbarConfig(): MenuConfig { + return { + icon: IconItalic, + }; + } + /** * Renders wrapper for tool without actual content * @returns Created html element diff --git a/packages/core/src/tools/internal/inline-tools/link/index.ts b/packages/core/src/tools/internal/inline-tools/link/index.ts index 57d15c29..8f5f8dfc 100644 --- a/packages/core/src/tools/internal/inline-tools/link/index.ts +++ b/packages/core/src/tools/internal/inline-tools/link/index.ts @@ -1,16 +1,30 @@ import type { - ActionsElementWithOptions, - ToolFormattingOptions, + EditorAPI, InlineTool, - InlineToolFormatData, InlineToolConstructor -} from '@editorjs/sdk'; -import { - ToolType + InlineToolConstructor, + InlineToolConstructorOptions, + InlineToolFormatData, + ToolFormattingOptions, + MenuConfig } from '@editorjs/sdk'; +import { ToolType, PopoverItemType } from '@editorjs/sdk'; +/** + * @todo Export these types from SDK so Tool doesn't need to add model as a dependency. + */ import type { InlineFragment, TextRange } from '@editorjs/model'; -import { FormattingAction } from '@editorjs/model'; -import { IntersectType } from '@editorjs/model'; +import { FormattingAction, IntersectType } from '@editorjs/model'; import { make } from '@editorjs/dom'; +import { IconLink, IconUnlink } from '@codexteam/icons'; + +/** + * @todo Type tools data through InlineTool interface generic + */ +interface LinkData { + /** + * Link href + */ + href: string; +} /** * Link Tool @@ -47,6 +61,89 @@ export class LinkInlineTool implements InlineTool { */ public intersectType: IntersectType = IntersectType.Replace; + /** + * EditorJS API instance + */ + #api: EditorAPI; + + /** + * Tool constructor function + * @param param0 - inline tool parameters + * @param param0.api - EditorJS api + */ + constructor({ api }: InlineToolConstructorOptions) { + this.#api = api; + } + + /** + * Returns inline toolbar configuration for the tool + * @param range - selected range + * @param fragments - fragments of the tool in the selected range + */ + public getToolbarConfig(range: TextRange, fragments: InlineFragment[]): MenuConfig { + const isActive = this.isActive(range, fragments); + const data = isActive ? fragments[0].data! : {} as LinkData; + + const linkInput = make('input', 'ejs-inline-toolbar__input', { + placeholder: 'Add a link', + enterKeyHint: 'done', + value: data.href ?? '', + }) as HTMLInputElement; + + return { + icon: isActive ? IconUnlink : IconLink, + onActivate: () => { + if (!isActive) { + return; + } + + const caretIndex = this.#api.selection.caretIndex; + + this.#api.selection.applyInlineTool({ + tool: LinkInlineTool.name, + data: {}, + caretIndex: caretIndex!, + action: FormattingAction.Unformat, + }); + }, + children: { + isFlippable: false, + items: [{ + type: PopoverItemType.Html, + element: linkInput, + }], + isOpen: isActive, + onOpen: (close: (parent?: boolean) => void): void => { + const caretIndex = this.#api.selection.caretIndex; + + linkInput.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); + + close(true); + + this.#api.selection.applyInlineTool({ + tool: LinkInlineTool.name, + data: { href: linkInput.value }, + caretIndex: caretIndex!, + /** @todo Replace link instead of applying the formatting again. Needs to be implemented in the model */ + action: isActive ? FormattingAction.None : FormattingAction.Format, + keepSelection: false, + }); + } + }); + + if (!isActive) { + queueMicrotask(() => { + linkInput.focus(); + }); + } + }, + }, + }; + } + /** * Renders wrapper for tool without actual content * @param data - inline tool data formed in toolbar @@ -55,8 +152,8 @@ export class LinkInlineTool implements InlineTool { public createWrapper(data: InlineToolFormatData): HTMLElement { const linkElement = make('a') as HTMLLinkElement; - if (typeof data.link === 'string') { - linkElement.href = data.link; + if (typeof data.href === 'string') { + linkElement.href = data.href; } return linkElement; @@ -87,7 +184,7 @@ export class LinkInlineTool implements InlineTool { /** * Check if current index is inside of model fragment */ - if (range[0] === fragment.range[0] && range[1] === fragment.range[1]) { + if (range[0] >= fragment.range[0] && range[1] <= fragment.range[1]) { isActive = true; /** @@ -99,23 +196,6 @@ export class LinkInlineTool implements InlineTool { return isActive; } - - /** - * Function that is responsible for rendering data form element - * @param callback function that should be triggered, when data completely formed - * @returns rendered data form element with options required in toolbar - */ - public renderActions(callback: (data: InlineToolFormatData) => void): ActionsElementWithOptions { - const linkInput = make('input') as HTMLInputElement; - - linkInput.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.key === 'Enter') { - callback({ link: linkInput.value }); - } - }); - - return { element: linkInput }; - } } LinkInlineTool satisfies InlineToolConstructor; diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index eb2e91d1..962d5c8c 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -41,23 +41,15 @@ export class FormattingAdapter { #caretAdapter: CaretAdapter; /** - * Editor's config - */ - #config: Required; - - /** - * @param config - Editor's config * @param api - Editor's API * @param caretAdapter - caret adapter instance * @param eventBus - Editor's EventBus instance */ constructor( - @inject(TOKENS.EditorConfig) config: Required, @inject(TOKENS.EditorAPI) api: EditorAPI, caretAdapter: CaretAdapter, eventBus: EventBus ) { - this.#config = config; this.#api = api; this.#caretAdapter = caretAdapter; @@ -134,45 +126,39 @@ export class FormattingAdapter { * @param event - model change event */ #handleModelUpdates(event: ModelEvents): void { - if (event instanceof TextFormattedEvent || event instanceof TextUnformattedEvent) { - const tool = this.#tools.get(event.detail.data.tool); - const { textRange, blockIndex, dataKey } = event.detail.index; - - if (tool === undefined || textRange === undefined || blockIndex === undefined || dataKey === undefined) { - return; - } - - const input = this.#caretAdapter.findInput(blockIndex, dataKey.toString()); - - if (input === undefined) { - console.warn('No input found for the index', event.detail.index); - - return; - } - - const inputContent = input.textContent; + if (!(event instanceof TextFormattedEvent || event instanceof TextUnformattedEvent)) { + return; + } - const rangeStart = Math.max(0, textRange[0] - 1); - const rangeEnd = inputContent !== null ? Math.min(inputContent.length, textRange[1] + 1) : 0; + const tool = this.#tools.get(event.detail.data.tool); + const { textRange, blockIndex, dataKey } = event.detail.index; - const affectedFragments = this.#api.text.getFragments({ - block: blockIndex, - key: dataKey as string, - start: rangeStart, - end: rangeEnd, - }); + if (tool === undefined || textRange === undefined || blockIndex === undefined || dataKey === undefined) { + return; + } + const input = this.#caretAdapter.findInput(blockIndex, dataKey.toString()); - const leftBoundary = affectedFragments[0]?.range[0] ?? textRange[0]; - let rightBoundary = textRange[1]; + if (input === undefined) { + console.warn('No input found for the index', event.detail.index); - for (const fragment of affectedFragments) { - rightBoundary = Math.max(rightBoundary, fragment.range[1]); - } - - this.#rerenderRange(input, leftBoundary, rightBoundary, affectedFragments); + return; + } + const inputContent = input.textContent; + const rangeStart = Math.max(0, textRange[0] - 1); + const rangeEnd = inputContent !== null ? Math.min(inputContent.length, textRange[1] + 1) : 0; + const affectedFragments = this.#api.text.getFragments({ + block: blockIndex, + key: dataKey as string, + start: rangeStart, + end: rangeEnd, + }); + const leftBoundary = affectedFragments[0]?.range[0] ?? textRange[0]; + let rightBoundary = textRange[1]; - this.#caretAdapter.updateIndex(event.detail.index, event.detail.userId); + for (const fragment of affectedFragments) { + rightBoundary = Math.max(rightBoundary, fragment.range[1]); } + this.#rerenderRange(input, leftBoundary, rightBoundary, affectedFragments); } /** diff --git a/packages/dom-adapters/src/utils/useSelectionChange.ts b/packages/dom-adapters/src/utils/useSelectionChange.ts index c723a4b3..4ab2c0a1 100644 --- a/packages/dom-adapters/src/utils/useSelectionChange.ts +++ b/packages/dom-adapters/src/utils/useSelectionChange.ts @@ -64,8 +64,18 @@ export const useSelectionChange = createSingleton(() => { /** * Handler for document "selection change" event. + * @param e - selection change event */ - function onDocumentSelectionChanged(): void { + function onDocumentSelectionChanged(e: Event): void { + /** + * Selection changes in the HTMLInputElement bubbles up. + * We don't support native inputs within the Editor document, + * so just ignore those events to avoid unnecessary processing. + */ + if (!(e.target instanceof Document)) { + return; + } + const selection = document.getSelection(); /** diff --git a/packages/model/src/entities/inline-fragments/FormattingInlineNode/types/FormattingAction.ts b/packages/model/src/entities/inline-fragments/FormattingInlineNode/types/FormattingAction.ts index f06b3e97..6b8cd57d 100644 --- a/packages/model/src/entities/inline-fragments/FormattingInlineNode/types/FormattingAction.ts +++ b/packages/model/src/entities/inline-fragments/FormattingInlineNode/types/FormattingAction.ts @@ -4,12 +4,17 @@ */ export enum FormattingAction { /** - * Apply formatting for selction + * Apply formatting for selection */ Format = 'format', /** * Delete formatting for selection */ - Unformat = 'unformat' + Unformat = 'unformat', + + /** + * If nothing should happen, tool can send None + */ + None = 'none' } diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index d608bb21..169a2569 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -10,7 +10,8 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, - "isolatedModules": true, + /** @todo Change back to true when codex-tooltip is fixed */ + "isolatedModules": false, "noEmit": true, "jsx": "preserve", @@ -26,8 +27,8 @@ }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [ - { - "path": "./tsconfig.node.json" + { + "path": "./tsconfig.node.json" }, { "path": "../core/tsconfig.json" diff --git a/packages/sdk/src/api/SelectionAPI.ts b/packages/sdk/src/api/SelectionAPI.ts index 6d2bb551..1222b1dd 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -1,4 +1,7 @@ -import type { Caret, CaretManagerEvents } from '@editorjs/model'; +/** + * @todo maybe invert dependency, so SDK is not dependant on the model + */ +import type { Caret, CaretManagerEvents, FormattingAction, Index } from '@editorjs/model'; /** * Selection API interface @@ -8,11 +11,38 @@ export interface SelectionAPI { /** * Applies inline tool for the current selection * @param params - method parameters - * @param params.tool - name of the inline tool to apply - * @param [params.data] - optional data for the inline tool */ - // eslint-disable-next-line jsdoc/require-jsdoc,@stylistic/object-property-newline -- type declaration - applyInlineTool({ tool, data }: { tool: string; data?: Record }): void; + applyInlineTool({ tool, data, caretIndex }: { + /** + * name of the inline tool to apply + */ + tool: string; + /** + * optional data for the inline tool + */ + data?: Record; + /** + * caret index where to apply the tool. By default — current caret index + */ + caretIndex?: Index; + /** + * ID of a user who made the change + */ + userId?: string | number; + + /** + * By default, method changes the tool state, + * with this option you can choose a specific action + */ + action?: FormattingAction; + + /** + * If true, selection will be restored after the tool is applied. + * If false, selection will be collapsed to the end + * By default equals true + */ + keepSelection?: boolean; + }): void; /** * Registers a callback for CaretManager updates. Returns a cleanup function @@ -31,4 +61,9 @@ export interface SelectionAPI { * @param userId - user id. If not provided, returns for current user */ getCaret(userId?: string | number): Caret | undefined; + + /** + * Current caret index + */ + caretIndex: Index | null; } diff --git a/packages/sdk/src/entities/BlockTool.ts b/packages/sdk/src/entities/BlockTool.ts index 09b7b8a5..535436c7 100644 --- a/packages/sdk/src/entities/BlockTool.ts +++ b/packages/sdk/src/entities/BlockTool.ts @@ -8,6 +8,7 @@ import type { ValueSerialized } from '@editorjs/model'; import type { BlockToolAdapter } from './BlockToolAdapter.js'; import type { ToolType } from '@/entities/EntityType.js'; import type { BaseToolConstructor, BaseToolOptions } from '@/entities/BaseTool'; +import type { EditorAPI } from '@/api'; /** * Configuration for converting block content to/from other block types. @@ -121,7 +122,7 @@ export interface BlockToolConstructorOptions< * if your tool needs access to DOM-specific methods such as setInput. */ Adapter extends BlockToolAdapter = BlockToolAdapter -> extends BlockToolConstructorOptionsVersion2 { +> extends Omit { /** * Block tool adapter will be passed to the tool to connect data with the DOM */ @@ -136,6 +137,11 @@ export interface BlockToolConstructorOptions< * Config could be passed by tools user through the Editor config */ config: Config; + + /** + * Editor's API instance + */ + api: EditorAPI; } /** diff --git a/packages/sdk/src/entities/EventBus/events/core/SelectionChangedCoreEvent.ts b/packages/sdk/src/entities/EventBus/events/core/SelectionChangedCoreEvent.ts index 8f2059e9..b5919ff2 100644 --- a/packages/sdk/src/entities/EventBus/events/core/SelectionChangedCoreEvent.ts +++ b/packages/sdk/src/entities/EventBus/events/core/SelectionChangedCoreEvent.ts @@ -1,7 +1,7 @@ -import type { InlineTool } from '@/entities/InlineTool.js'; import { CoreEventBase } from './CoreEventBase.js'; import { CoreEventType } from './CoreEventType.js'; -import type { Index, InlineFragment, InlineToolName } from '@editorjs/model'; +import type { Index, InlineFragment } from '@editorjs/model'; +import type { InlineToolFacade } from '@/tools'; /** * Payload of SelectionChangedCoreEvent custom event @@ -16,7 +16,7 @@ export interface SelectionChangedCoreEventPayload { /** * Inline tools available for the current selection */ - readonly availableInlineTools: Map; + readonly availableInlineTools: InlineToolFacade[]; /** * Inline fragments available for the current selection diff --git a/packages/sdk/src/entities/InlineTool.ts b/packages/sdk/src/entities/InlineTool.ts index 35ca6190..dfed8a86 100644 --- a/packages/sdk/src/entities/InlineTool.ts +++ b/packages/sdk/src/entities/InlineTool.ts @@ -3,6 +3,8 @@ import type { InlineTool as InlineToolVersion2 } from '@editorjs/editorjs'; import type { InlineToolConstructorOptions as InlineToolConstructorOptionsVersion2, ToolConfig } from '@editorjs/editorjs'; import type { ToolType } from '@/entities/EntityType.js'; import type { BaseToolConstructor, BaseToolOptions } from '@/entities/BaseTool'; +import type { EditorAPI } from '@/api'; +import type { MenuConfig } from '@/entities/MenuConfig.js'; /** * Canonical keys for Inline Tool options. @@ -38,8 +40,12 @@ export interface InlineToolOptions /** * Extended InlineToolConstructorOptions interface for version 3. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface InlineToolConstructorOptions extends InlineToolConstructorOptionsVersion2 {} +export interface InlineToolConstructorOptions extends Omit { + /** + * EditorJS API instance + */ + api: EditorAPI; +} /** * Object represents formatting action with text range to be applied on @@ -68,22 +74,6 @@ export interface ToolbarOptions { fakeSelectionRequired: boolean; } -/** - * Interface that represents return type of the renderActions function of the tool - * Contains rendered by tool renderActions with options for toolbar - */ -export interface ActionsElementWithOptions { - /** - * HTML element rendered by tool for data forming - */ - element: HTMLElement; - - /** - * Options of custom toolbar behaviour - */ - toolbarOptions?: ToolbarOptions; -} - export type InlineToolFormatData = Record; /** @@ -117,10 +107,9 @@ export interface InlineTool extends Omit void): ActionsElementWithOptions; + getToolbarConfig(index: TextRange, fragments: InlineFragment[]): MenuConfig | Promise; } /** @@ -133,7 +122,7 @@ export interface InlineToolsConfig extends Record * Inline Tool constructor class */ export interface InlineToolConstructor extends BaseToolConstructor { - new(): InlineTool; + new(params: InlineToolConstructorOptions): InlineTool; /** * Property specifies the entity is an Inline Tool diff --git a/packages/sdk/src/entities/MenuConfig.ts b/packages/sdk/src/entities/MenuConfig.ts new file mode 100644 index 00000000..db7ea4a2 --- /dev/null +++ b/packages/sdk/src/entities/MenuConfig.ts @@ -0,0 +1,60 @@ +/** + * @todo think on how to remove ui-kit dependency here + */ +import type { PopoverItemChildren, PopoverItemDefaultBaseParams, PopoverItemHtmlParams, PopoverItemSeparatorParams, WithChildren } from '@editorjs/ui-kit'; +import { PopoverItemType } from '@editorjs/ui-kit'; + +/** + * @todo maybe reexport under "MenuItemType" + */ +export { PopoverItemType }; + +/** + * Menu configuration format. + * Is used for defining Block Tunes Menu items via Block Tool's renderSettings(), Block Tune's render() and Inline Tool's render(). + */ +export type MenuConfig = MenuConfigItem | MenuConfigItem[]; + +/** + * Common parameters for all kinds of default Menu Config items: with or without confirmation + * Only icon is required + */ +type MenuConfigDefaultBaseParams = Partial; + +/** + * Menu Config item parameters with confirmation + */ +type MenuConfigItemDefaultWithConfirmationParams = Omit & { + /** + * Items with confirmation should not have onActivate handler + */ + onActivate?: never; + + /** + * Menu Config item parameters that should be applied on item activation. + * May be used to ask user for confirmation before executing item activation handler. + */ + confirmation: MenuConfigDefaultBaseParams; +}; + +type MenuConfigItemWithChildren = MenuConfigDefaultBaseParams & { + /** + * Popover item children configuration + */ + children: PopoverItemChildren; +}; + +/** + * Default, non-separator and non-html Menu Config items type + */ +type MenuConfigItemDefaultParams = MenuConfigDefaultBaseParams + | MenuConfigItemWithChildren + | MenuConfigItemDefaultWithConfirmationParams; + +/** + * Single Menu Config item + */ +type MenuConfigItem = MenuConfigItemDefaultParams + | PopoverItemSeparatorParams + | PopoverItemHtmlParams + | WithChildren; diff --git a/packages/sdk/src/entities/index.ts b/packages/sdk/src/entities/index.ts index 12d13483..587a7cd1 100644 --- a/packages/sdk/src/entities/index.ts +++ b/packages/sdk/src/entities/index.ts @@ -9,3 +9,4 @@ export type * from './EditorjsPlugin.js'; export * from './EntityType.js'; export * from './IndexError.js'; export type * from './EditorjsAdapterPlugin.js'; +export * from './MenuConfig.js'; diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts index 76d4207c..c7365567 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc,@typescript-eslint/no-magic-numbers */ import { describe, expect, it } from '@jest/globals'; -import type { API as ApiMethods } from '@editorjs/editorjs'; import type { BlockToolConstructor, BlockToolData } from '../../entities/BlockTool.js'; import { BlockToolOptionKey } from '../../entities/BlockTool.js'; import { ToolType } from '../../entities/EntityType.js'; @@ -10,8 +9,9 @@ import { UserToolOptions } from './BaseToolFacade.js'; import { BlockToolFacade } from './BlockToolFacade.js'; import type { InlineFragment } from '@editorjs/model'; import { BlockChildType, NODE_TYPE_HIDDEN_PROP } from '@editorjs/model'; +import type { EditorAPI } from '@/api'; -const emptyApi = {} as ApiMethods; +const emptyApi = {} as EditorAPI; /** * Block tool facade with only fields needed to exercise BaseToolFacade getters. diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index 043e743b..4a379317 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -1,6 +1,4 @@ import type { - // SanitizerConfig, - API as ApiMethods, Tool } from '@editorjs/editorjs'; import { isFunction } from '@editorjs/helpers'; @@ -14,6 +12,7 @@ import type { } from '../../entities'; import type { ToolStaticOptions, BlockToolOptions, InlineToolOptions, BlockTuneOptions } from '../../entities/BaseTool.js'; import { BaseToolOptionKey } from '../../entities/BaseTool.js'; +import type { EditorAPI } from '@/api'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- need to allow any type here so extended interfaces pass export type ToolConstructable = BlockToolConstructor | InlineToolConstructor | BlockTuneConstructor; @@ -65,7 +64,7 @@ interface ConstructorOptions { /** * Api methods for the Tool */ - api: ApiMethods; + api: EditorAPI; /** * Is tool default @@ -95,7 +94,7 @@ export abstract class BaseToolFacade this.#handleSelectionChange(event)); + this.#eventBus.addEventListener(`core:${CoreEventType.SelectionChanged}`, (event: SelectionChangedCoreEvent) => void this.#handleSelectionChange(event)); } /** @@ -65,7 +86,7 @@ export class InlineToolbarUI implements EditorjsPlugin { * Handles the selection change core event * @param event - SelectionChangedCoreEvent event */ - #handleSelectionChange(event: SelectionChangedCoreEvent): void { + async #handleSelectionChange(event: SelectionChangedCoreEvent): Promise { const { availableInlineTools, index, fragments } = event.detail; const selection = window.getSelection(); const segments = index?.getTextSegments() ?? []; @@ -103,7 +124,7 @@ export class InlineToolbarUI implements EditorjsPlugin { return; } - this.#updateToolsList(availableInlineTools, textRange, fragments); + await this.#renderPopover(availableInlineTools, textRange, fragments); this.#move(); this.#show(); } @@ -114,34 +135,117 @@ export class InlineToolbarUI implements EditorjsPlugin { #render(): void { this.#nodes.holder = make('div', Style['inline-toolbar']); - this.#nodes.holder.style.display = 'none'; - this.#nodes.holder.style.position = 'absolute'; + this.#eventBus.dispatchEvent(new InlineToolbarRenderedUIEvent({ toolbar: this.#nodes.holder })); + } - this.#nodes.buttons = make('div', Style['inline-toolbar-list']); - this.#nodes.holder.appendChild(this.#nodes.buttons); + /** + * Creates a new InlinePopover instance and adds it to the Editor UI + * @param availableInlineTools - inline tools to render in the toolbar + * @param textRange - selected text range + * @param fragments - inline tool fragments for the selected text range + */ + async #renderPopover( + availableInlineTools: InlineToolFacade[], + textRange: TextRange, + fragments: InlineFragment[] + ): Promise { + if (this.#popover !== null) { + this.#popover.destroy(); + this.#popover = null; + } - this.#nodes.actions = make('div', Style['inline-toolbar-actions']); - this.#nodes.holder.appendChild(this.#nodes.actions); + const popoverItems = Array.from(availableInlineTools).map(async (tool, i) => { + const toolFragments = fragments.filter((fragment: InlineFragment) => fragment.tool === tool.name); + const shortcut = tool.options.shortcut; + const instance = tool.create(); + const toolbarConfig = await instance.getToolbarConfig(textRange, toolFragments); + + const shortcutBeautified = shortcut !== undefined ? beautifyShortcut(shortcut) : undefined; + const toolTitle = capitalize(tool.options[InlineToolOptionKey.Title] ?? tool.name); + + const popoverItemParams: PopoverItemDefaultBaseParams = { + name: tool.name, + onActivate: () => this.#onToolClick(tool), + isActive: () => instance.isActive( + textRange, + toolFragments + ), + hint: { + title: toolTitle, + description: shortcutBeautified, + }, + }; + + return [toolbarConfig] + .flat() + .map((item): PopoverItemParams[] => { + switch (item.type) { + case PopoverItemType.Html: + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- TS doesn't see its as any + return [{ + ...popoverItemParams, + ...item, + }]; + case PopoverItemType.Separator: + return [{ + type: PopoverItemType.Separator, + }]; + + case PopoverItemType.Default: + default: + const items: PopoverItemParams[] = [ + { + ...popoverItemParams, + ...item, + type: PopoverItemType.Default, + }, + ]; + + if ('children' in item && i !== 0) { + items.unshift({ + type: PopoverItemType.Separator, + }); + } + + if ('children' in item && i < availableInlineTools.length - 1) { + items.push({ + type: PopoverItemType.Separator, + }); + } + + return items; + } + }) + .flat(); + }); - this.#eventBus.dispatchEvent(new InlineToolbarRenderedUIEvent({ toolbar: this.#nodes.holder })); + this.#popover = new PopoverInline({ + items: (await Promise.all(popoverItems)).flat(), + scopeElement: this.#config.holder, + closeOnOutsideClick: false, + }); + + this.#nodes.holder.appendChild(this.#popover.getElement()); } /** * Shows the Inline Toolbar */ #show(): void { - this.#nodes.holder.style.display = 'block'; + this.#popover?.show(); } /** * Hides the Inline Toolbar */ #hide(): void { - this.#nodes.holder.style.display = 'none'; + this.#popover?.hide(); + this.#popover?.destroy(); } /** * Moves the Inline Toolbar to the current selection + * @todo Think on how it should work for cross-block selection */ #move(): void { const selection = window.getSelection(); @@ -154,64 +258,31 @@ export class InlineToolbarUI implements EditorjsPlugin { const rect = range.getBoundingClientRect(); - this.#nodes.holder.style.top = `${rect.top}px`; - this.#nodes.holder.style.left = `${rect.left}px`; - this.#nodes.holder.style.zIndex = '1000'; - } - - /** - * Renders the list of available inline tools in the Inline Toolbar - * @param tools - Inline Tools available for the current selection - * @param textRange - current selection text range - * @param fragments - inline fragments for the current selection - */ - #updateToolsList(tools: Map, textRange: TextRange, fragments: InlineFragment[]): void { - this.#nodes.buttons.innerHTML = ''; - - Array.from(tools.entries()).forEach(([name, tool]) => { - const button = make('button'); - - button.textContent = name; - - const isActive = tool.isActive(textRange, fragments.filter((fragment: InlineFragment) => fragment.tool === name)); + // Use offsetParent (the positioned ancestor) instead of holder to ensure accurate positioning + // when the toolbar is appended to a different container + const offsetParent = this.#nodes.holder.offsetParent as HTMLElement; + const offsetParentRect = offsetParent?.getBoundingClientRect() ?? { x: 0, + y: 0, + top: 0 }; - if (isActive) { - button.style.fontWeight = 'bold'; - } + const newPosition = { + x: rect.x - offsetParentRect.x, + y: rect.y + rect.height - offsetParentRect.top, + } as const; - if (Object.hasOwnProperty.call(tool.constructor.prototype, 'renderActions')) { - button.addEventListener('click', () => { - this.#renderToolActions(name, tool); - }); - } else { - button.addEventListener('click', () => { - this.#api.selection.applyInlineTool({ tool: name }); - }); - } + /** + * @todo add right overflow handling + */ - this.#nodes.buttons.appendChild(button); - }); + this.#nodes.holder.style.top = `${newPosition.y}px`; + this.#nodes.holder.style.left = `${newPosition.x}px`; } /** - * Renders the actions for the inline tool - * @param name - name of the inline tool to render actions for - * @param tool - inline tool instance + * Applies the inline tool to the current selection + * @param tool - tool to apply */ - #renderToolActions(name: InlineToolName, tool: InlineTool): void { - const { element } = tool.renderActions?.((data: InlineToolFormatData) => { - this.#api.selection.applyInlineTool({ - tool: name, - data, - }); - }) ?? { element: null }; - - if (element === null) { - return; - } - - this.#nodes.actions.innerHTML = ''; - - this.#nodes.actions.appendChild(element); + #onToolClick(tool: InlineToolFacade): void { + this.#api.selection.applyInlineTool({ tool: tool.name }); } }