diff --git a/packages/core/package.json b/packages/core/package.json index 5a30bd3d..f2870856 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,7 +21,10 @@ "@editorjs/dom": "^1.0.0", "@editorjs/dom-adapters": "workspace:^", "@editorjs/editorjs": "^2.30.5", + "@editorjs/helpers": "^1.0.0", "@editorjs/model": "workspace:^", - "@editorjs/sdk": "workspace:^" + "@editorjs/sdk": "workspace:^", + "reflect-metadata": "^0.2.2", + "typedi": "^0.10.0" } } diff --git a/packages/core/src/entities/UnifiedToolConfig.ts b/packages/core/src/entities/UnifiedToolConfig.ts new file mode 100644 index 00000000..f2c38247 --- /dev/null +++ b/packages/core/src/entities/UnifiedToolConfig.ts @@ -0,0 +1,27 @@ +import type { ToolSettings, ToolConstructable } from '@editorjs/editorjs'; +import type { BlockToolConstructor, InlineToolConstructor } from '@editorjs/sdk'; + +/** + * Users can pass tool's config in two ways: + * toolName: ToolClass + * or + * toolName: { + * class: ToolClass, + * // .. other options + * } + * + * This interface unifies these variants to a single format + */ +export type UnifiedToolConfig = Record & { + /** + * Tool constructor + */ + class: ToolConstructable | BlockToolConstructor | InlineToolConstructor; + + /** + * Specifies if tool is internal + * + * Internal tools set it to true, external tools omit it + */ + isInternal?: boolean; +}>; diff --git a/packages/core/src/entities/index.ts b/packages/core/src/entities/index.ts index 72f6900e..5c995404 100644 --- a/packages/core/src/entities/index.ts +++ b/packages/core/src/entities/index.ts @@ -1 +1,2 @@ export * from './Config.js'; +export * from './UnifiedToolConfig.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6b9103a..c0f5a6d7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,9 +1,11 @@ import type { ModelEvents } from '@editorjs/model'; import { BlockAddedEvent, EditorJSModel, EventType } from '@editorjs/model'; +import type { ContainerInstance } from 'typedi'; +import { Container } from 'typedi'; import { composeDataFromVersion2 } from './utils/composeDataFromVersion2.js'; import ToolsManager from './tools/ToolsManager.js'; import { BlockToolAdapter, CaretAdapter, InlineToolsAdapter } from '@editorjs/dom-adapters'; -import type { BlockAPI, BlockToolData, API as EditorjsApi, ToolConfig } from '@editorjs/editorjs'; +import type { BlockAPI, BlockToolData } from '@editorjs/editorjs'; import { InlineToolbar } from './ui/InlineToolbar/index.js'; import type { CoreConfigValidated } from './entities/Config.js'; import type { BlockTool, CoreConfig } from '@editorjs/sdk'; @@ -42,6 +44,11 @@ export default class Core { */ #caretAdapter: CaretAdapter; + /** + * Inversion of Control container for dependency injections + */ + #iocContainer: ContainerInstance; + /** * Inline tool adapter is responsible for handling model formatting updates * Applies format, got from inline toolbar to the model @@ -61,19 +68,32 @@ export default class Core { * @param config - Editor configuration */ constructor(config: CoreConfig) { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + this.#iocContainer = Container.of(Math.floor(Math.random() * 1e10).toString()); + this.validateConfig(config); this.#config = config as CoreConfigValidated; + this.#iocContainer.set('EditorConfig', this.#config); + const { blocks } = composeDataFromVersion2(config.data ?? { blocks: [] }); this.#model = new EditorJSModel(); + + this.#iocContainer.set(EditorJSModel, this.#model); + this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.handleModelUpdate(event)); - this.#toolsManager = new ToolsManager(this.#config.tools); + this.#toolsManager = this.#iocContainer.get(ToolsManager); + this.#caretAdapter = new CaretAdapter(this.#config.holder, this.#model); + this.#iocContainer.set(CaretAdapter, this.#caretAdapter); + this.#inlineToolsAdapter = new InlineToolsAdapter(this.#model, this.#caretAdapter); + this.#iocContainer.set(InlineToolsAdapter, this.#inlineToolsAdapter); - this.#inlineToolbar = new InlineToolbar(this.#model, this.#inlineToolsAdapter, this.#toolsManager.getInlineTools(), this.#config.holder); + this.#inlineToolbar = new InlineToolbar(this.#model, this.#inlineToolsAdapter, this.#toolsManager.inlineTools, this.#config.holder); + this.#iocContainer.set(InlineToolbar, this.#inlineToolbar); this.#model.initializeDocument({ blocks }); } @@ -160,14 +180,15 @@ export default class Core { */ data: BlockToolData>; }, blockToolAdapter: BlockToolAdapter): BlockTool { - const tool = this.#toolsManager.resolveBlockTool(name); - const block = new tool({ + const tool = this.#toolsManager.blockTools.get(name); + + if (!tool) { + throw new Error(`Block Tool ${name} not found`); + } + + const block = tool.create({ adapter: blockToolAdapter, data: data, - - // @todo - api: {} as EditorjsApi, - config: {} as ToolConfig>, block: {} as BlockAPI, readOnly: false, }); diff --git a/packages/core/src/tools/ToolsManager.ts b/packages/core/src/tools/ToolsManager.ts index 286cdf6e..73e2d835 100644 --- a/packages/core/src/tools/ToolsManager.ts +++ b/packages/core/src/tools/ToolsManager.ts @@ -1,42 +1,239 @@ -import type { BlockToolConstructor, InlineToolsConfig } from '@editorjs/sdk'; +import 'reflect-metadata'; +import { deepMerge, isFunction, isObject, PromiseQueue } from '@editorjs/helpers'; +import { Inject, Service } from 'typedi'; +import { + BlockToolFacade, BlockTuneFacade, + InlineToolFacade, + ToolsCollection, + ToolsFactory +} from './facades/index.js'; import { Paragraph } from './internal/block-tools/paragraph/index.js'; -import type { EditorConfig } from '@editorjs/editorjs'; +import type { + EditorConfig, + ToolConstructable, + ToolSettings +} from '@editorjs/editorjs'; import BoldInlineTool from './internal/inline-tools/bold/index.js'; import ItalicInlineTool from './internal/inline-tools/italic/index.js'; +import { BlockToolConstructor, InlineTool, InlineToolConstructor } from '@editorjs/sdk'; +import { UnifiedToolConfig } from '../entities/index.js'; /** * Works with tools + * @todo - validate tools configurations + * @todo - merge internal tools */ +@Service() export default class ToolsManager { - #tools: EditorConfig['tools']; + /** + * ToolsFactory instance + */ + #factory: ToolsFactory; + + /** + * Unified config with internal and internal tools + */ + #config: UnifiedToolConfig; + + /** + * Tools available for use + */ + #availableTools = new ToolsCollection(); + + /** + * Tools loaded but unavailable for use + */ + #unavailableTools = new ToolsCollection(); + + /** + * Returns available Tools + */ + public get available(): ToolsCollection { + return this.#availableTools; + } + + /** + * Returns unavailable Tools + */ + public get unavailable(): ToolsCollection { + return this.#unavailableTools; + } + + /** + * Return Tools for the Inline Toolbar + */ + public get inlineTools(): ToolsCollection { + return this.available.inlineTools; + } + + /** + * Return editor block tools + */ + public get blockTools(): ToolsCollection { + return this.available.blockTools; + } + + /** + * Return available Block Tunes + * @returns - object of Inline Tool's classes + */ + public get blockTunes(): ToolsCollection { + return this.available.blockTunes; + } /** - * @param tools - Tools configuration passed by user + * Returns internal tools */ - constructor(tools: EditorConfig['tools']) { - this.#tools = tools; + public get internal(): ToolsCollection { + return this.available.internalTools; } /** - * Returns a block tool by its name - * @param toolName - name of a tool to resolve + * @param editorConfig - EditorConfig object + * @param editorConfig.tools - Tools configuration passed by user */ - public resolveBlockTool(toolName: string): BlockToolConstructor { - switch (toolName) { - case 'paragraph': - return Paragraph; - default: - throw new Error(`Unknown tool: ${toolName}`); + constructor(@Inject('EditorConfig') editorConfig: EditorConfig) { + this.#config = this.#prepareConfig(editorConfig.tools ?? {}); + + this.#validateTools(); + + this.#factory = new ToolsFactory(this.#config, editorConfig, {}); + + void this.prepareTools(); + } + + /** + * Calls tools prepare method if it exists and adds tools to relevant collection (available or unavailable tools) + * @returns Promise + */ + public async prepareTools(): Promise { + const promiseQueue = new PromiseQueue(); + + Object.entries(this.#config).forEach(([toolName, config]) => { + if (isFunction(config.class.prepare)) { + void promiseQueue.add(async () => { + try { + /** + * TypeScript doesn't get type guard here, so non-null assertion is used + */ + await config.class.prepare!({ + toolName: toolName, + config: config, + }); + + const tool = this.#factory.get(toolName); + + if (tool.isInline()) { + /** + * Some Tools validation + */ + const inlineToolRequiredMethods = ['render']; + const notImplementedMethods = inlineToolRequiredMethods.filter(method => tool.create()[method as keyof InlineTool] !== undefined); + + if (notImplementedMethods.length) { + /** + * @todo implement logger + */ + console.log( + `Incorrect Inline Tool: ${tool.name}. Some of required methods is not implemented %o`, + 'warn', + notImplementedMethods + ); + + this.#unavailableTools.set(tool.name, tool); + + return; + } + } + + this.#availableTools.set(toolName, tool); + } catch (e) { + console.error(`Tool ${toolName} failed to prepare`, e); + + this.#unavailableTools.set(toolName, this.#factory.get(toolName)); + } + }); + } else { + this.#availableTools.set(toolName, this.#factory.get(toolName)); + } + }); + + await promiseQueue.completed; + } + + /** + * Unify tools config + * @param config - user's tools config + */ + #prepareConfig(config: EditorConfig['tools']): UnifiedToolConfig { + const unifiedConfig: UnifiedToolConfig = {} as UnifiedToolConfig; + + /** + * Save Tools settings to a map + */ + for (const toolName in config) { + /** + * If Tool is an object not a Tool's class then + * save class and settings separately + */ + if (isObject(config)) { + unifiedConfig[toolName] = config[toolName] as UnifiedToolConfig[string]; + } else { + unifiedConfig[toolName] = { class: config[toolName] as ToolConstructable }; + } } + + deepMerge(unifiedConfig, this.#internalTools); + + return unifiedConfig; } /** - * Returns inline tools got from the EditorConfig tools + * Validate Tools configuration objects and throw Error for user if it is invalid */ - public getInlineTools(): InlineToolsConfig { + #validateTools(): void { + /** + * Check Tools for a class containing + */ + for (const toolName in this.#config) { + if (Object.prototype.hasOwnProperty.call(this.#config, toolName)) { + // if (toolName in this.internalTools) { + // return; + // } + + const tool = this.#config[toolName]; + + if (!isFunction(tool) && !isFunction((tool as ToolSettings).class)) { + throw Error( + `Tool «${toolName}» must be a constructor function or an object with function in the «class» property` + ); + } + } + } + } + + /** + * Returns internal tools + * Includes Bold, Italic, Link and Paragraph + */ + get #internalTools(): UnifiedToolConfig { return { - bold: BoldInlineTool, - italic: ItalicInlineTool, + paragraph: { + /** + * @todo solve problems with types + */ + class: Paragraph as unknown as BlockToolConstructor, + inlineToolbar: true, + isInternal: true, + }, + bold: { + class: BoldInlineTool as unknown as InlineToolConstructor, + isInternal: true, + }, + italic: { + class: ItalicInlineTool as unknown as InlineToolConstructor, + isInternal: true, + }, }; - }; + } } diff --git a/packages/core/src/tools/facades/BaseToolFacade.ts b/packages/core/src/tools/facades/BaseToolFacade.ts new file mode 100644 index 00000000..dfd4a7d0 --- /dev/null +++ b/packages/core/src/tools/facades/BaseToolFacade.ts @@ -0,0 +1,301 @@ +import type { + SanitizerConfig, + API as ApiMethods, + Tool, + ToolConstructable as ToolConstructableV2, + ToolSettings +} from '@editorjs/editorjs'; +import { isFunction } from '@editorjs/helpers'; +import { type BlockToolFacade } from './BlockToolFacade.js'; +import { type InlineToolFacade } from './InlineToolFacade.js'; +import { ToolType } from './ToolType.js'; +import { type BlockTuneFacade } from './BlockTuneFacade.js'; +import type { BlockTool, BlockToolConstructor, InlineTool, InlineToolConstructor } from '@editorjs/sdk'; + +export type ToolConstructable = ToolConstructableV2 | BlockToolConstructor | InlineToolConstructor; + +/** + * Enum of Tool options provided by user + */ +export enum UserSettings { + /** + * Shortcut for Tool + */ + Shortcut = 'shortcut', + /** + * Toolbox config for Tool + */ + Toolbox = 'toolbox', + /** + * Enabled Inline Tools for Block Tool + */ + EnabledInlineTools = 'inlineToolbar', + /** + * Enabled Block Tunes for Block Tool + */ + EnabledBlockTunes = 'tunes', + /** + * Tool configuration + */ + Config = 'config' +} + +/** + * Enum of Tool options provided by Tool + */ +export enum CommonInternalSettings { + /** + * Shortcut for Tool + */ + Shortcut = 'shortcut', + /** + * Sanitize configuration for Tool + */ + SanitizeConfig = 'sanitize' + +} + +/** + * Enum of Tool options provided by Block Tool + */ +export enum InternalBlockToolSettings { + /** + * Is line breaks enabled for Tool + */ + IsEnabledLineBreaks = 'enableLineBreaks', + /** + * Tool Toolbox config + */ + Toolbox = 'toolbox', + /** + * Tool conversion config + */ + ConversionConfig = 'conversionConfig', + /** + * Is readonly mode supported for Tool + */ + IsReadOnlySupported = 'isReadOnlySupported', + /** + * Tool paste config + */ + PasteConfig = 'pasteConfig' +} + +/** + * Enum of Tool options provided by Inline Tool + */ +export enum InternalInlineToolSettings { + /** + * Flag specifies Tool is inline + */ + IsInline = 'isInline', + /** + * Inline Tool title for toolbar + */ + Title = 'title' // for Inline Tools. Block Tools can pass title along with icon through the 'toolbox' static prop. +} + +/** + * Enum of Tool options provided by Block Tune + */ +export enum InternalTuneSettings { + /** + * Flag specifies Tool is Block Tune + */ + IsTune = 'isTune' +} + +export type ToolOptions = Omit; + +/** + * BlockToolFacade constructor options inteface + */ +interface ConstructorOptions { + /** + * Tool name + */ + name: string; + + /** + * Tool constructor function/class + */ + constructable: ToolConstructable; + + /** + * Tool options + */ + config: ToolOptions; + + /** + * Api methods for the Tool + */ + api: ApiMethods; + + /** + * Is tool default + */ + isDefault: boolean; + + /** + * Is tool internal + */ + isInternal: boolean; + + /** + * Defualt placaholder for the Tol + */ + defaultPlaceholder?: string | false; +} + +/** + * Base abstract class for Tools + */ +// eslint-disable-next-line @stylistic/type-generic-spacing +export abstract class BaseToolFacade { + /** + * Tool name specified in EditorJS config + */ + public name: string; + + /** + * Flag show is current Tool internal (bundled with EditorJS core) or not + */ + public readonly isInternal: boolean; + + /** + * Flag show is current Tool default or not + */ + public readonly isDefault: boolean; + + /** + * EditorJS API for current Tool + */ + protected api: ApiMethods; + + /** + * Current tool user configuration + */ + protected config: ToolOptions; + + /** + * Tool's constructable blueprint + */ + protected readonly constructable: ToolConstructable; + + /** + * Default placeholder specified in EditorJS user configuration + */ + protected defaultPlaceholder?: string | false; + + /** + * Tool type: Block, Inline or Tune + */ + public abstract type: Type; + + /** + * BaseToolFacade constructor function + * @param options - BaseToolFacade constructor paramaters + */ + constructor({ + name, + constructable, + config, + api, + isDefault, + isInternal = false, + defaultPlaceholder, + }: ConstructorOptions) { + this.api = api; + this.name = name; + this.constructable = constructable; + this.config = config; + this.isDefault = isDefault; + this.isInternal = isInternal; + this.defaultPlaceholder = defaultPlaceholder; + } + + /** + * Returns Tool user configuration + */ + public get settings(): ToolOptions { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const config = this.config[UserSettings.Config] ?? {}; + + if (this.isDefault && !('placeholder' in config) && typeof this.defaultPlaceholder === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + config.placeholder = this.defaultPlaceholder; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return config; + } + + /** + * Calls Tool's reset method + */ + public reset(): void | Promise { + if (isFunction(this.constructable.reset)) { + return this.constructable.reset(); + } + } + + /** + * Calls Tool's prepare method + */ + public prepare(): void | Promise { + if (isFunction(this.constructable.prepare)) { + return this.constructable.prepare({ + toolName: this.name, + config: this.settings, + }); + } + } + + /** + * Returns shortcut for Tool (internal or specified by user) + */ + public get shortcut(): string | undefined { + /** + * @todo check if we support user shortcuts as static property as it is not specified in types + */ + // const toolShortcut = this.constructable[CommonInternalSettings.Shortcut]; + const userShortcut = this.config[UserSettings.Shortcut]; + + return userShortcut; // || toolShortcut; + } + + /** + * Returns Tool's sanitizer configuration + */ + public get sanitizeConfig(): SanitizerConfig { + return this.constructable[CommonInternalSettings.SanitizeConfig] || {}; + } + + /** + * Returns true if Tools is inline + */ + public isInline(): this is InlineToolFacade { + return this.type === ToolType.Inline; + } + + /** + * Returns true if Tools is block + */ + public isBlock(): this is BlockToolFacade { + return this.type === ToolType.Block; + } + + /** + * Returns true if Tools is tune + */ + public isTune(): this is BlockTuneFacade { + return this.type === ToolType.Tune; + } + + /** + * Constructs new Tool instance from constructable blueprint + * @param args + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public abstract create(...args: any[]): ToolClass; +} diff --git a/packages/core/src/tools/facades/BlockToolFacade.ts b/packages/core/src/tools/facades/BlockToolFacade.ts new file mode 100644 index 00000000..1cd195f6 --- /dev/null +++ b/packages/core/src/tools/facades/BlockToolFacade.ts @@ -0,0 +1,217 @@ +import { BaseToolFacade, InternalBlockToolSettings, UserSettings } from './BaseToolFacade.js'; +import type { + ConversionConfig, + PasteConfig, + SanitizerConfig, + ToolboxConfig, + ToolboxConfigEntry +} from '@editorjs/editorjs'; +import { isEmpty, cacheable, isObject } from '@editorjs/helpers'; +import { type InlineToolFacade } from './InlineToolFacade.js'; +import { ToolType } from './ToolType.js'; +import { type BlockTuneFacade } from './BlockTuneFacade.js'; +import { ToolsCollection } from './ToolsCollection.js'; +import { BlockToolConstructor as BlockToolConstructable, BlockToolConstructorOptions, BlockTool as IBlockTool } from '@editorjs/sdk'; + +/** + * Class to work with Block tools constructables + */ +export class BlockToolFacade extends BaseToolFacade { + /** + * Tool type for BlockToolFacade tools — Block + */ + public type: ToolType.Block = ToolType.Block; + + /** + * InlineTool collection for current Block Tool + */ + public inlineTools: ToolsCollection = new ToolsCollection(); + + /** + * BlockTune collection for current Block Tool + */ + public tunes: ToolsCollection = new ToolsCollection(); + + /** + * Tool's constructable blueprint + */ + protected declare constructable: BlockToolConstructable; + + /** + * Creates new Tool instance + * @param options - Tool constructor options + * @param options.data - Tools data + * @param options.block - BlockAPI for current Block + * @param options.readOnly - True if Editor is in read-only mode + */ + public create({ data, block, readOnly, adapter }: Pick): IBlockTool { + return new this.constructable({ + adapter, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data, + block, + readOnly, + api: this.api, + config: this.settings, + }); + } + + /** + * Returns true if read-only mode is supported by Tool + */ + public get isReadOnlySupported(): boolean { + return this.constructable[InternalBlockToolSettings.IsReadOnlySupported] === true; + } + + /** + * Returns true if Tool supports linebreaks + * @todo check if we still need this as BlockToolConstructable doesn't have line breaks option + */ + // public get isLineBreaksEnabled(): boolean { + // return this.constructable[InternalBlockToolSettings.IsEnabledLineBreaks]; + // } + + /** + * Returns Tool toolbox configuration (internal or user-specified). + * + * Merges internal and user-defined toolbox configs based on the following rules: + * + * - If both internal and user-defined toolbox configs are arrays their items are merged. + * Length of the second one is kept. + * + * - If both are objects their properties are merged. + * + * - If one is an object and another is an array than internal config is replaced with user-defined + * config. This is made to allow user to override default tool's toolbox representation (single/multiple entries) + */ + public get toolbox(): ToolboxConfigEntry[] | undefined { + const toolToolboxSettings = this.constructable[InternalBlockToolSettings.Toolbox] as ToolboxConfig; + const userToolboxSettings = this.config[UserSettings.Toolbox]; + + if (isEmpty(toolToolboxSettings)) { + return; + } + if (userToolboxSettings === false) { + return; + } + /** + * Return tool's toolbox settings if user settings are not defined + */ + if (!userToolboxSettings) { + return Array.isArray(toolToolboxSettings) ? toolToolboxSettings : [toolToolboxSettings]; + } + + /** + * Otherwise merge user settings with tool's settings + */ + if (Array.isArray(toolToolboxSettings)) { + if (Array.isArray(userToolboxSettings)) { + return userToolboxSettings.map((item, i) => { + const toolToolboxEntry = toolToolboxSettings[i]; + + if (toolToolboxEntry !== undefined) { + return { + ...toolToolboxEntry, + ...item, + }; + } + + return item; + }); + } + + return [userToolboxSettings]; + } else { + if (Array.isArray(userToolboxSettings)) { + return userToolboxSettings; + } + + return [ + { + ...toolToolboxSettings, + ...userToolboxSettings, + }, + ]; + } + } + + /** + * Returns Tool conversion configuration + */ + public get conversionConfig(): ConversionConfig | undefined { + return this.constructable[InternalBlockToolSettings.ConversionConfig]; + } + + /** + * Returns enabled inline tools for Tool + */ + public get enabledInlineTools(): boolean | string[] { + return this.config[UserSettings.EnabledInlineTools] ?? false; + } + + /** + * Returns enabled tunes for Tool + */ + public get enabledBlockTunes(): boolean | string[] { + return this.config[UserSettings.EnabledBlockTunes] ?? false; + } + + /** + * Returns Tool paste configuration + */ + public get pasteConfig(): PasteConfig { + return this.constructable[InternalBlockToolSettings.PasteConfig] ?? {}; + } + + /** + * Returns sanitize configuration for Block Tool including configs from related Inline Tools and Block Tunes + */ + @cacheable + public get sanitizeConfig(): SanitizerConfig { + const toolRules = super.sanitizeConfig; + const baseConfig = this.baseSanitizeConfig; + + if (isEmpty(toolRules)) { + return baseConfig; + } + + const toolConfig = {} as SanitizerConfig; + + for (const fieldName in toolRules) { + if (Object.prototype.hasOwnProperty.call(toolRules, fieldName)) { + const rule = toolRules[fieldName]; + + /** + * If rule is object, merge it with Inline Tools configuration + * + * Otherwise pass as it is + */ + if (isObject(rule)) { + toolConfig[fieldName] = Object.assign({}, baseConfig, rule); + } else { + toolConfig[fieldName] = rule; + } + } + } + + return toolConfig; + } + + /** + * Returns sanitizer configuration composed from sanitize config of Inline Tools enabled for Tool + */ + @cacheable + public get baseSanitizeConfig(): SanitizerConfig { + const baseConfig = {}; + + Array + .from(this.inlineTools.values()) + .forEach(tool => Object.assign(baseConfig, tool.sanitizeConfig)); + + Array + .from(this.tunes.values()) + .forEach(tune => Object.assign(baseConfig, tune.sanitizeConfig)); + + return baseConfig; + } +} diff --git a/packages/core/src/tools/facades/BlockTuneFacade.ts b/packages/core/src/tools/facades/BlockTuneFacade.ts new file mode 100644 index 00000000..c29d8d4e --- /dev/null +++ b/packages/core/src/tools/facades/BlockTuneFacade.ts @@ -0,0 +1,36 @@ +import { BaseToolFacade } from './BaseToolFacade.js'; +import type { BlockAPI, BlockTune as IBlockTune, BlockTuneConstructable } from '@editorjs/editorjs'; +import { ToolType } from './ToolType.js'; +// import type { BlockTuneData } from '@editorjs/editorjs'; + +/** + * Stub class for BlockTunes + * @todo Implement + */ +export class BlockTuneFacade extends BaseToolFacade { + /** + * Tool type — Tune + */ + public type: ToolType.Tune = ToolType.Tune; + + /** + * Tool's constructable blueprint + */ + protected declare constructable: BlockTuneConstructable; + + /** + * Constructs new BlockTune instance from constructable + * @param data - Tunes data + * @param block - Block API object + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public create(data: any, block: BlockAPI): IBlockTune { + return new this.constructable({ + api: this.api, + config: this.settings, + block, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data, + }); + } +} diff --git a/packages/core/src/tools/facades/InlineToolFacade.ts b/packages/core/src/tools/facades/InlineToolFacade.ts new file mode 100644 index 00000000..d21c932d --- /dev/null +++ b/packages/core/src/tools/facades/InlineToolFacade.ts @@ -0,0 +1,38 @@ +import { BaseToolFacade, InternalInlineToolSettings } from './BaseToolFacade.js'; +import type { InlineTool as IInlineTool, InlineToolConstructor as InlineToolConstructable } from '@editorjs/sdk'; +import { ToolType } from './ToolType.js'; + +/** + * InlineTool object to work with Inline Tools constructables + */ +export class InlineToolFacade extends BaseToolFacade { + /** + * Tool type for InlineToolFacade tools — Inline + */ + public type: ToolType.Inline = ToolType.Inline; + + /** + * Tool's constructable blueprint + */ + protected declare constructable: InlineToolConstructable; + + /** + * Returns title for Inline Tool if specified by user + */ + public get title(): string | undefined { + return this.constructable[InternalInlineToolSettings.Title]; + } + + /** + * Constructs new InlineTool instance from constructable + */ + public create(): IInlineTool { + /** + * @todo fix types + */ + return new this.constructable({ + api: this.api, + config: this.settings, + }) as unknown as IInlineTool; + } +} diff --git a/packages/core/src/tools/facades/ToolType.ts b/packages/core/src/tools/facades/ToolType.ts new file mode 100644 index 00000000..78689195 --- /dev/null +++ b/packages/core/src/tools/facades/ToolType.ts @@ -0,0 +1,18 @@ +/** + * What kind of plugins developers can create + */ +export enum ToolType { + /** + * Block tool + */ + Block, + /** + * Inline tool + */ + Inline, + + /** + * Block tune + */ + Tune +} diff --git a/packages/core/src/tools/facades/ToolsCollection.ts b/packages/core/src/tools/facades/ToolsCollection.ts new file mode 100644 index 00000000..d4a5e0e7 --- /dev/null +++ b/packages/core/src/tools/facades/ToolsCollection.ts @@ -0,0 +1,65 @@ +import { type BlockToolFacade } from './BlockToolFacade.js'; +import { type InlineToolFacade } from './InlineToolFacade.js'; +import { type BlockTuneFacade } from './BlockTuneFacade.js'; + +export type ToolClass = BlockToolFacade | InlineToolFacade | BlockTuneFacade; + +/** + * Class to store Editor Tools + */ +export class ToolsCollection extends Map { + /** + * Returns Block Tools collection + */ + public get blockTools(): ToolsCollection { + const tools = Array + .from(this.entries()) + .filter(([, tool]) => tool.isBlock()) as [string, BlockToolFacade][]; + + return new ToolsCollection(tools); + } + + /** + * Returns Inline Tools collection + */ + public get inlineTools(): ToolsCollection { + const tools = Array + .from(this.entries()) + .filter(([, tool]) => tool.isInline()) as [string, InlineToolFacade][]; + + return new ToolsCollection(tools); + } + + /** + * Returns Block Tunes collection + */ + public get blockTunes(): ToolsCollection { + const tools = Array + .from(this.entries()) + .filter(([, tool]) => tool.isTune()) as [string, BlockTuneFacade][]; + + return new ToolsCollection(tools); + } + + /** + * Returns internal Tools collection + */ + public get internalTools(): ToolsCollection { + const tools = Array + .from(this.entries()) + .filter(([, tool]) => tool.isInternal); + + return new ToolsCollection(tools); + } + + /** + * Returns Tools collection provided by user + */ + public get externalTools(): ToolsCollection { + const tools = Array + .from(this.entries()) + .filter(([, tool]) => !tool.isInternal); + + return new ToolsCollection(tools); + } +} diff --git a/packages/core/src/tools/facades/ToolsFactory.ts b/packages/core/src/tools/facades/ToolsFactory.ts new file mode 100644 index 00000000..8dd38a1f --- /dev/null +++ b/packages/core/src/tools/facades/ToolsFactory.ts @@ -0,0 +1,97 @@ +/* eslint-disable jsdoc/informative-docs */ +import type { BlockToolConstructor, InlineToolConstructor } from '@editorjs/sdk'; +import { InternalInlineToolSettings, InternalTuneSettings } from './BaseToolFacade.js'; +import { InlineToolFacade } from './InlineToolFacade.js'; +import { BlockTuneFacade } from './BlockTuneFacade.js'; +import { BlockToolFacade } from './BlockToolFacade.js'; +// import type ApiModule from '../modules/api'; +import type { + ToolConstructable, + EditorConfig, + InlineToolConstructable, + BlockTuneConstructable +} from '@editorjs/editorjs'; +import type { UnifiedToolConfig } from '../../entities/UnifiedToolConfig.js'; + +type ToolConstructor = typeof InlineToolFacade | typeof BlockToolFacade | typeof BlockTuneFacade; + +/** + * Factory to construct classes to work with tools + */ +export class ToolsFactory { + /** + * Tools configuration specified by user + */ + private config: UnifiedToolConfig; + + /** + * EditorJS API Module + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private api: any; + + /** + * EditorJS configuration + */ + private editorConfig: EditorConfig; + + /** + * ToolsFactory + * @param config - unified tools config for user`s and internal tools + * @param editorConfig - full Editor.js configuration + * @param api - EditorJS module with all Editor methods + */ + constructor( + config: UnifiedToolConfig, + editorConfig: EditorConfig, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api: any + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.api = api; + this.config = config; + this.editorConfig = editorConfig; + } + + /** + * Returns Tool object based on it's type + * @param name - tool name + */ + public get(name: string): InlineToolFacade | BlockToolFacade | BlockTuneFacade { + const { class: constructable, isInternal = false, ...config } = this.config[name]; + + const Constructor = this.getConstructor(constructable); + // const isTune = constructable[InternalTuneSettings.IsTune]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return new Constructor({ + name, + constructable, + config, + api: {}, + // api: this.api.getMethodsForTool(name, isTune), + isDefault: name === this.editorConfig.defaultBlock, + defaultPlaceholder: this.editorConfig.placeholder, + isInternal, + /** + * @todo implement api.getMethodsForTool + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + } + + /** + * Find appropriate Tool object constructor for Tool constructable + * @param constructable - Tools constructable + */ + private getConstructor(constructable: ToolConstructable | BlockToolConstructor | InlineToolConstructor): ToolConstructor { + switch (true) { + case (constructable as InlineToolConstructable)[InternalInlineToolSettings.IsInline]: + return InlineToolFacade; + case (constructable as BlockTuneConstructable)[InternalTuneSettings.IsTune]: + return BlockTuneFacade; + default: + return BlockToolFacade; + } + } +} diff --git a/packages/core/src/tools/facades/index.ts b/packages/core/src/tools/facades/index.ts new file mode 100644 index 00000000..cd655e06 --- /dev/null +++ b/packages/core/src/tools/facades/index.ts @@ -0,0 +1,6 @@ +export * from './BlockToolFacade.js'; +export * from './BlockTuneFacade.js'; +export * from './InlineToolFacade.js'; +export * from './ToolsFactory.js'; +export * from './ToolsCollection.js'; +export * from './ToolType.js'; diff --git a/packages/core/src/ui/InlineToolbar/index.ts b/packages/core/src/ui/InlineToolbar/index.ts index 06c01200..7e85fd1f 100644 --- a/packages/core/src/ui/InlineToolbar/index.ts +++ b/packages/core/src/ui/InlineToolbar/index.ts @@ -1,9 +1,9 @@ import type { InlineToolsAdapter } from '@editorjs/dom-adapters'; -import type { InlineTool, InlineToolsConfig } from '@editorjs/sdk'; import type { InlineToolName } from '@editorjs/model'; -import { type EditorJSModel, type TextRange, createInlineToolData, createInlineToolName, Index } from '@editorjs/model'; +import { type EditorJSModel, type TextRange, createInlineToolData, Index } from '@editorjs/model'; import { EventType } from '@editorjs/model'; import { make } from '@editorjs/dom'; +import type { InlineToolFacade, ToolsCollection } from '../../tools/facades/index.js'; /** * Class determines, when inline toolbar should be rendered @@ -31,7 +31,7 @@ export class InlineToolbar { /** * Tools that would be attached to the adapter */ - #tools: Map; + #tools: ToolsCollection; /** * Toolbar html element related to the editor @@ -46,18 +46,11 @@ export class InlineToolbar { * @param tools - tools, that should be attached to adapter * @param holder - editor holder element */ - constructor(model: EditorJSModel, inlineToolAdapter: InlineToolsAdapter, tools: InlineToolsConfig, holder: HTMLElement) { + constructor(model: EditorJSModel, inlineToolAdapter: InlineToolsAdapter, tools: ToolsCollection, holder: HTMLElement) { this.#model = model; this.#inlineToolAdapter = inlineToolAdapter; this.#holder = holder; - this.#tools = new Map(); - - Object.entries(tools).forEach(([toolName, toolConstructable]) => { - /** - * @todo - support inline tool options - */ - this.#tools.set(createInlineToolName(toolName), new toolConstructable()); - }); + this.#tools = tools; this.#attachTools(); @@ -99,8 +92,8 @@ export class InlineToolbar { * Attach all tools passed to the inline tool adapter */ #attachTools(): void { - this.#tools.forEach((tool, toolName) => { - this.#inlineToolAdapter.attachTool(toolName, tool); + Array.from(this.#tools.entries()).forEach(([toolName, tool]) => { + this.#inlineToolAdapter.attachTool(toolName as InlineToolName, tool.create()); }); } @@ -130,13 +123,13 @@ export class InlineToolbar { this.#toolbar = make('div'); - this.#tools.forEach((_tool, toolName) => { + Array.from(this.#tools.keys()).forEach((toolName) => { const inlineElementButton = make('button'); inlineElementButton.innerHTML = toolName; inlineElementButton.addEventListener('click', (_event) => { - this.apply(toolName); + this.apply(toolName as InlineToolName); }); if (this.#toolbar !== undefined) { this.#toolbar.appendChild(inlineElementButton); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index 3a9adb89..8850f3e5 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -6,6 +6,9 @@ "references": [ { "path": "../model/tsconfig.build.json" + }, + { + "path": "../sdk/tsconfig.build.json" } ] } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 14b9a083..dfbd8664 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -9,6 +9,7 @@ "strict": true, "skipLibCheck": true, "experimentalDecorators": true, + "emitDecoratorMetadata": true, "types": ["jest"], "rootDir": "src", "outDir": "dist" diff --git a/packages/sdk/src/entities/BlockTool.ts b/packages/sdk/src/entities/BlockTool.ts index 82c0aa16..0c58c453 100644 --- a/packages/sdk/src/entities/BlockTool.ts +++ b/packages/sdk/src/entities/BlockTool.ts @@ -1,4 +1,4 @@ -import type { BlockTool as BlockToolVersion2, ToolConfig } from '@editorjs/editorjs'; +import type { BlockTool as BlockToolVersion2, BlockToolConstructable as BlockToolConstructableV2, ToolConfig } from '@editorjs/editorjs'; import type { BlockToolConstructorOptions as BlockToolConstructorOptionsVersion2 } from '@editorjs/editorjs'; import type { ValueSerialized } from '@editorjs/model'; import { BlockToolAdapter } from './BlockToolAdapter'; @@ -57,7 +57,7 @@ export type BlockTool< /** * Block Tool constructor class */ -export type BlockToolConstructor = new (options: BlockToolConstructorOptions) => BlockTool; +export type BlockToolConstructor = BlockToolConstructableV2 & (new (options: BlockToolConstructorOptions) => BlockTool); /** * Data structure describing the tool's input/output data diff --git a/packages/sdk/src/entities/InlineTool.ts b/packages/sdk/src/entities/InlineTool.ts index 61b443c7..d5e07c11 100644 --- a/packages/sdk/src/entities/InlineTool.ts +++ b/packages/sdk/src/entities/InlineTool.ts @@ -1,5 +1,5 @@ import type { TextRange, InlineFragment, FormattingAction, IntersectType } from '@editorjs/model'; -import type { InlineTool as InlineToolVersion2 } from '@editorjs/editorjs'; +import type { InlineTool as InlineToolVersion2, InlineToolConstructable as InlineToolConstructableV2 } from '@editorjs/editorjs'; import type { InlineToolConstructorOptions as InlineToolConstructorOptionsVersion2 } from '@editorjs/editorjs'; /** @@ -64,4 +64,4 @@ export interface InlineToolsConfig extends Record * @todo support options: InlineToolConstructableOptions * Inline Tool constructor class */ -export type InlineToolConstructor = new () => InlineTool; +export type InlineToolConstructor = InlineToolConstructableV2 & (new () => InlineTool); diff --git a/yarn.lock b/yarn.lock index 9d775f20..a4fd0ab8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -634,10 +634,13 @@ __metadata: "@editorjs/dom": "npm:^1.0.0" "@editorjs/dom-adapters": "workspace:^" "@editorjs/editorjs": "npm:^2.30.5" + "@editorjs/helpers": "npm:^1.0.0" "@editorjs/model": "workspace:^" "@editorjs/sdk": "workspace:^" eslint: "npm:^9.9.1" eslint-config-codex: "npm:^2.0.2" + reflect-metadata: "npm:^0.2.2" + typedi: "npm:^0.10.0" typescript: "npm:^5.5.4" languageName: unknown linkType: soft @@ -6891,6 +6894,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.2.2": + version: 0.2.2 + resolution: "reflect-metadata@npm:0.2.2" + checksum: 1c93f9ac790fea1c852fde80c91b2760420069f4862f28e6fae0c00c6937a56508716b0ed2419ab02869dd488d123c4ab92d062ae84e8739ea7417fae10c4745 + languageName: node + linkType: hard + "regenerator-runtime@npm:^0.14.0": version: 0.14.1 resolution: "regenerator-runtime@npm:0.14.1" @@ -7933,6 +7943,13 @@ __metadata: languageName: node linkType: hard +"typedi@npm:^0.10.0": + version: 0.10.0 + resolution: "typedi@npm:0.10.0" + checksum: 3d519795b4d1a9edea2e5fdb9fa61bc2eca00546aeaf2dbbc1597cfed021659ca117454913022828c77f4971dd03f38a3f9a646eda374ccfb565d3ddcb661224 + languageName: node + linkType: hard + "typescript-eslint@npm:^8.3.0": version: 8.3.0 resolution: "typescript-eslint@npm:8.3.0"