From 83662ffb8690b313bf14d22ed5d0b6fa07beb92d Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:29:17 +0300 Subject: [PATCH] feat(dom-adapters): Inline tool adapter check for tool required data (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implmenet global CaretAdapter * Handle native inputs * Pass input type to Input component props * Use class to represent index * Fix lint in dom-adapters * fix linter * added inline tool adapter * implement model updates * lint fix * fix index * adapter renders inline tools * lint fix and clean up * jsdoc * clean up * jsdoc * jsdoc * surround content replaced * suggestions * lint fix * jsdoc * added bold and italic inline tools into core package * naming * naming * added inline toolbar and inlineToolAdapter init into core * update packages and lock * build fix * implement inline tool adapter to core - fully implement current realization of inline tool adapter to core - remove from the playground * clean up * jsdoc and naming improvements * naming * naming * renaming * fix hardcoded * tools are initialized inside of the inline toolbar initialization * fixed inline tool attaching * jsdoc * naming fix * fixed imports * lint fix * try build fix * install dependencies * add sdk package * fix build for core * change package name in actions * add references * typo * fix build * added inline tool data former * fix lint * rm unwanted changes * lint fix * fixed build * docs improved * fix build * naming improved * Update packages/core/src/ui/InlineToolbar/index.ts Co-authored-by: Peter * Update packages/dom-adapters/src/FormattingAdapter/index.ts Co-authored-by: Peter * rm unwanted change * naming * separated renderToolActions and apply method in formatting adapter * naming * moved surround to utils * lint fix * last naming fix 🤞 * made renderToolActions method private --------- Co-authored-by: George Berezhnoy Co-authored-by: George Berezhnoy Co-authored-by: Peter --- packages/core/src/index.ts | 2 + packages/core/src/tools/ToolsManager.ts | 16 ++- .../src/tools/facades/InlineToolFacade.ts | 7 ++ .../tools/internal/inline-tools/bold/index.ts | 16 +-- .../internal/inline-tools/italic/index.ts | 16 +-- .../tools/internal/inline-tools/link/index.ts | 101 ++++++++++++++++++ packages/core/src/ui/InlineToolbar/index.ts | 65 +++++++++-- .../src/BlockToolAdapter/index.ts | 2 +- .../index.ts | 77 +++++++++++-- packages/dom-adapters/src/index.ts | 2 +- packages/dom-adapters/src/utils/surround.ts | 18 ++++ packages/sdk/src/entities/InlineTool.ts | 45 ++++++-- 12 files changed, 321 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/tools/internal/inline-tools/link/index.ts rename packages/dom-adapters/src/{InlineToolsAdapter => FormattingAdapter}/index.ts (57%) create mode 100644 packages/dom-adapters/src/utils/surround.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3df7ecf5..36f8ce44 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,8 @@ export default class Core { this.#iocContainer = Container.of(Math.floor(Math.random() * 1e10).toString()); this.validateConfig(config); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unnecessary-type-assertion this.#config = config as CoreConfigValidated; this.#iocContainer.set('EditorConfig', this.#config); diff --git a/packages/core/src/tools/ToolsManager.ts b/packages/core/src/tools/ToolsManager.ts index 73e2d835..13dd3067 100644 --- a/packages/core/src/tools/ToolsManager.ts +++ b/packages/core/src/tools/ToolsManager.ts @@ -1,9 +1,10 @@ +import type { BlockToolConstructor } from '@editorjs/sdk'; import 'reflect-metadata'; import { deepMerge, isFunction, isObject, PromiseQueue } from '@editorjs/helpers'; import { Inject, Service } from 'typedi'; +import type { BlockToolFacade, BlockTuneFacade, + InlineToolFacade } from './facades/index.js'; import { - BlockToolFacade, BlockTuneFacade, - InlineToolFacade, ToolsCollection, ToolsFactory } from './facades/index.js'; @@ -13,10 +14,11 @@ import type { ToolConstructable, ToolSettings } from '@editorjs/editorjs'; +import { InlineTool, InlineToolConstructor } from '@editorjs/sdk'; +import type { UnifiedToolConfig } from '../entities/index.js'; 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'; +import LinkInlineTool from './internal/inline-tools/link/index.js'; /** * Works with tools @@ -25,6 +27,8 @@ import { UnifiedToolConfig } from '../entities/index.js'; */ @Service() export default class ToolsManager { + #tools: EditorConfig['tools']; + /** * ToolsFactory instance */ @@ -234,6 +238,10 @@ export default class ToolsManager { class: ItalicInlineTool as unknown as InlineToolConstructor, isInternal: true, }, + link: { + class: LinkInlineTool as unknown as InlineToolConstructor, + isInternal: true, + }, }; } } diff --git a/packages/core/src/tools/facades/InlineToolFacade.ts b/packages/core/src/tools/facades/InlineToolFacade.ts index d21c932d..54e7320c 100644 --- a/packages/core/src/tools/facades/InlineToolFacade.ts +++ b/packages/core/src/tools/facades/InlineToolFacade.ts @@ -23,6 +23,13 @@ export class InlineToolFacade extends BaseToolFacade { /** * Check if current index is inside of model fragment */ - if (index[0] >= fragment.range[0] && index[1] <= fragment.range[1]) { + if (range[0] >= fragment.range[0] && range[1] <= fragment.range[1]) { isActive = true; /** 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 71107723..ab43a0cd 100644 --- a/packages/core/src/tools/internal/inline-tools/italic/index.ts +++ b/packages/core/src/tools/internal/inline-tools/italic/index.ts @@ -1,4 +1,4 @@ -import type { FormattingActionWithRange, InlineTool } from '@editorjs/sdk'; +import type { ToolFormattingOptions, InlineTool } from '@editorjs/sdk'; import type { InlineFragment, TextRange } from '@editorjs/model'; import { FormattingAction } from '@editorjs/model'; import { IntersectType } from '@editorjs/model'; @@ -34,30 +34,30 @@ export default class ItalicInlineTool implements InlineTool { /** * Returns formatting action and range for it to be applied - * @param index - index of current text selection + * @param range - range of current text selection * @param fragments - all fragments of the bold inline tool inside of the current input */ - public getAction(index: TextRange, fragments: InlineFragment[]): FormattingActionWithRange { + public getFormattingOptions(range: TextRange, fragments: InlineFragment[]): ToolFormattingOptions { return { - action: this.isActive(index, fragments) ? FormattingAction.Unformat : FormattingAction.Format, - range: index, + action: this.isActive(range, fragments) ? FormattingAction.Unformat : FormattingAction.Format, + range, }; }; /** * Returns state of the bold inline tool - * @param index - index of current selection + * @param range - range of current selection * @param fragments - all fragments of the bold inline tool inside of the current input * @returns true if tool is active, false otherwise */ - public isActive(index: TextRange, fragments: InlineFragment[]): boolean { + public isActive(range: TextRange, fragments: InlineFragment[]): boolean { let isActive = false; fragments.forEach((fragment) => { /** * Check if current index is inside of model fragment */ - if (index[0] >= fragment.range[0] && index[1] <= fragment.range[1]) { + if (range[0] >= fragment.range[0] && range[1] <= fragment.range[1]) { isActive = true; /** diff --git a/packages/core/src/tools/internal/inline-tools/link/index.ts b/packages/core/src/tools/internal/inline-tools/link/index.ts new file mode 100644 index 00000000..2265a430 --- /dev/null +++ b/packages/core/src/tools/internal/inline-tools/link/index.ts @@ -0,0 +1,101 @@ +import type { ActionsElementWithOptions, ToolFormattingOptions, InlineTool, InlineToolFormatData } 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'; + +/** + * Link Tool + * + * Inline Toolbar Tool + * + * Makes selected text linked + */ +export default class LinkInlineTool implements InlineTool { + /** + * Specifies Tool as Inline Toolbar Tool + * @returns {boolean} + */ + public static isInline = true; + + /** + * Type of behaviour of the tool if new selection range intersect with existing fragment + * If two fragment intersect, existing fragment should be replaced with new one + */ + public intersectType: IntersectType = IntersectType.Replace; + + /** + * Renders wrapper for tool without actual content + * @param data - inline tool data formed in toolbar + * @returns Created html element + */ + public createWrapper(data: InlineToolFormatData): HTMLElement { + const linkElement = make('a') as HTMLLinkElement; + + if (typeof data.link === 'string') { + linkElement.href = data.link; + } + + return linkElement; + } + + /** + * Returns formatting action and range for it to be applied + * @param range - range of current text selection + * @param fragments - all fragments of the bold inline tool inside of the current input + */ + public getFormattingOptions(range: TextRange, fragments: InlineFragment[]): ToolFormattingOptions { + return { + action: this.isActive(range, fragments) ? FormattingAction.Unformat : FormattingAction.Format, + range, + }; + }; + + /** + * Returns state of the bold inline tool + * @param range - range of current selection + * @param fragments - all fragments of the bold inline tool inside of the current input + * @returns true if tool is active, false otherwise + */ + public isActive(range: TextRange, fragments: InlineFragment[]): boolean { + let isActive = false; + + fragments.forEach((fragment) => { + /** + * Check if current index is inside of model fragment + */ + if (range[0] === fragment.range[0] && range[1] === fragment.range[1]) { + isActive = true; + + /** + * No need to check other fragments if state already chaned + */ + return; + } + }); + + 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') { + /** + * Remove link input, when data formed and trigger callback + */ + linkInput.remove(); + + callback({ link: linkInput.value }); + } + }); + + return { element: linkInput }; + } +} diff --git a/packages/core/src/ui/InlineToolbar/index.ts b/packages/core/src/ui/InlineToolbar/index.ts index 5984a797..a2f8143e 100644 --- a/packages/core/src/ui/InlineToolbar/index.ts +++ b/packages/core/src/ui/InlineToolbar/index.ts @@ -1,6 +1,7 @@ import type { FormattingAdapter } from '@editorjs/dom-adapters'; +import type { InlineToolFormatData } from '@editorjs/sdk'; import type { InlineToolName } from '@editorjs/model'; -import { type EditorJSModel, type TextRange, createInlineToolData, Index } from '@editorjs/model'; +import { type EditorJSModel, type TextRange, createInlineToolData, createInlineToolName, Index } from '@editorjs/model'; import { EventType } from '@editorjs/model'; import { make } from '@editorjs/dom'; import type { InlineToolFacade, ToolsCollection } from '../../tools/facades/index.js'; @@ -38,6 +39,14 @@ export class InlineToolbar { */ #toolbar: HTMLElement | undefined = undefined; + /** + * Actions of the current tool html element rendered inside of the toolbar element + */ + #actionsElement: HTMLElement | undefined = undefined; + + /** + * Holder element of the editor + */ #holder: HTMLElement; /** @@ -123,14 +132,25 @@ export class InlineToolbar { this.#toolbar = make('div'); - Array.from(this.#tools.keys()).forEach((toolName) => { + this.#tools.forEach((tool, toolName) => { const inlineElementButton = make('button'); inlineElementButton.innerHTML = toolName; - inlineElementButton.addEventListener('click', (_event) => { - this.apply(toolName as InlineToolName); - }); + /** + * If tool has actions, then on click of the element button we should render actions element + * If tool has no action, then on click of the element button we should apply format + */ + if (tool.hasActions) { + inlineElementButton.addEventListener('click', (_event) => { + this.#renderToolActions(createInlineToolName(toolName)); + }); + } else { + inlineElementButton.addEventListener('click', (_event) => { + this.apply(createInlineToolName(toolName), createInlineToolData({})); + }); + } + if (this.#toolbar !== undefined) { this.#toolbar.appendChild(inlineElementButton); } @@ -147,13 +167,38 @@ export class InlineToolbar { } /** - * Apply format with data formed in toolbar - * @param toolName - name of the inline tool, whose format would be applied + * Render actions to form data, which is required in tool + * Element that is used for forming data is rendered inside of the tool instance + * This function adds actions element to the toolbar + * @param nameOfTheTool - name of the inline tool, whose format would be applied */ - public apply(toolName: InlineToolName): void { + #renderToolActions(nameOfTheTool: InlineToolName): void { + const elementWithOptions = this.#formattingAdapter.createToolActions(nameOfTheTool, (data: InlineToolFormatData): void => { + this.apply(nameOfTheTool, data); + }); + + if (this.#toolbar === undefined) { + throw new Error('InlineToolbar: can not show tool actions without toolbar'); + } + /** - * @todo pass to applyFormat inline tool data formed in toolbar + * If actions element already exists, replace it with new one + * This check is needed to prevent displaying of several actions elements */ - this.#formattingAdapter.applyFormat(toolName, createInlineToolData({})); + if (this.#actionsElement !== undefined) { + this.#actionsElement.remove(); + } + + this.#actionsElement = elementWithOptions.element; + this.#holder.appendChild(this.#actionsElement); }; + + /** + * Apply format of the inline tool to the model + * @param toolName - name of the tool which format would be applied + * @param formatData - formed data required in the inline tool + */ + public apply(toolName: InlineToolName, formatData: InlineToolFormatData): void { + this.#formattingAdapter.applyFormat(toolName, createInlineToolData(formatData)); + } } diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 68f3c5b5..2d897dda 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -21,7 +21,7 @@ import { } from '../utils/index.js'; import { InputType } from './types/InputType.js'; import type { BlockToolAdapter as BlockToolAdapterInterface } from '@editorjs/sdk'; -import type { FormattingAdapter } from '../InlineToolsAdapter/index.js'; +import type { FormattingAdapter } from '../FormattingAdapter/index.js'; /** * BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model diff --git a/packages/dom-adapters/src/InlineToolsAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts similarity index 57% rename from packages/dom-adapters/src/InlineToolsAdapter/index.ts rename to packages/dom-adapters/src/FormattingAdapter/index.ts index d2b72b9f..cf32f09a 100644 --- a/packages/dom-adapters/src/InlineToolsAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -1,5 +1,7 @@ + import type { EditorJSModel, + InlineFragment, InlineToolData, InlineToolName, ModelEvents @@ -10,7 +12,8 @@ import { } from '@editorjs/model'; import type { CaretAdapter } from '../CaretAdapter/index.js'; import { FormattingAction } from '@editorjs/model'; -import type { InlineTool } from '@editorjs/sdk'; +import type { InlineTool, InlineToolFormatData, ActionsElementWithOptions } from '@editorjs/sdk'; +import { surround } from '../utils/surround.js'; /** * Class handles on format model events and renders inline tools @@ -48,6 +51,7 @@ export class FormattingAdapter { } /** + * @todo move event handling to BlockToolAdapter * Handles text format and unformat model events * * @param event - model change event @@ -68,17 +72,48 @@ export class FormattingAdapter { if (selection) { const range = selection.getRangeAt(0); - const inlineElement = tool.createWrapper(); + const inlineElement = tool.createWrapper(event.detail.data.data); - /** - * Insert contents from range to new inline element and put created element in range - */ - inlineElement.appendChild(range.extractContents()); - range.insertNode(inlineElement); + surround(range, inlineElement); } } } + /** + * Allows to render formatting inside a passed input + * + * @param input - input element to apply format to + * @param inlineFragment - instance that contains index, toolName and toolData + * @param inlineFragment.index - text range inside of the input element + * @param inlineFragment.toolName - name of the tool, which format to apply + * @param inlineFragment.toolData - additional data for the tool + */ + public formatElementContent(input: HTMLElement, inlineFragment: InlineFragment): void { + const toolName = inlineFragment.tool; + const toolData = inlineFragment.data; + const index = inlineFragment.range; + + const tool = this.#tools.get(toolName); + + if (tool === undefined) { + throw new Error(`FormattingAdapter: tool ${toolName} is not attached`); + } + + const [start, end] = index; + + /** + * Create range with positions specified in index + */ + const range = document.createRange(); + + range.setStart(input, start); + range.setEnd(input, end); + + const inlineElement = tool.createWrapper(toolData); + + surround(range, inlineElement); + } + /** * Attaches InlineTool to the adapter * @@ -98,6 +133,32 @@ export class FormattingAdapter { this.#tools.delete(toolName); } + /** + * Function that call tool's render actions method if it is specified, otherwise triggers callback + * If any data for tool is required - return rendered by tool data form element with options required in toolbar + * If data for tool is not required (tool don't need any data to apply format) - trigger callback with empty data + * + * @param toolName - name of the tool to check if data is required + * @param callback - callback function that should be triggered, when data completely formed + * @returns {ActionsElementWithOptions | null} rendered data form element with options required in toolbar or null if no data required + */ + public createToolActions(toolName: InlineToolName, callback: (data: InlineToolFormatData) => void): ActionsElementWithOptions { + const currentTool = this.#tools.get(toolName); + + if (currentTool === undefined) { + throw new Error(`FormattingAdapter: tool ${toolName} was not attached`); + } + + /** + * If renderActions method specified, render element and return it + */ + if (currentTool.renderActions === undefined) { + throw new Error(`FormattingAdapter: render actions method is not specified in tool ${toolName}`); + } + + return currentTool.renderActions(callback); + } + /** * Format model according to action formed by inline tool instance * @@ -134,7 +195,7 @@ export class FormattingAdapter { const fragments = this.#model.getFragments(blockIndex, dataKey, ...textRange, toolName); - const { action, range } = tool.getAction(textRange, fragments); + const { action, range } = tool.getFormattingOptions(textRange, fragments); switch (action) { case FormattingAction.Format: diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index 66842658..f4d9f900 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,3 +1,3 @@ export * from './CaretAdapter/index.js'; export * from './BlockToolAdapter/index.js'; -export * from './InlineToolsAdapter/index.js'; +export * from './FormattingAdapter/index.js'; diff --git a/packages/dom-adapters/src/utils/surround.ts b/packages/dom-adapters/src/utils/surround.ts new file mode 100644 index 00000000..9dfc54b2 --- /dev/null +++ b/packages/dom-adapters/src/utils/surround.ts @@ -0,0 +1,18 @@ +/** + * Function, that surrounds passed range with passed html element + * + * @param range - range to be surrounded + * @param wrapper - wrapper to surround the range + */ +export function surround(range: Range, wrapper: HTMLElement): void { + const inlineElement = wrapper; + + const extracted = range.extractContents(); + + /** + * Insert contents from range to new inline element and put created element in range + */ + inlineElement.appendChild(extracted); + + range.insertNode(inlineElement); +} diff --git a/packages/sdk/src/entities/InlineTool.ts b/packages/sdk/src/entities/InlineTool.ts index d5e07c11..3cd6fede 100644 --- a/packages/sdk/src/entities/InlineTool.ts +++ b/packages/sdk/src/entities/InlineTool.ts @@ -1,5 +1,6 @@ -import type { TextRange, InlineFragment, FormattingAction, IntersectType } from '@editorjs/model'; -import type { InlineTool as InlineToolVersion2, InlineToolConstructable as InlineToolConstructableV2 } from '@editorjs/editorjs'; +import type { TextRange, InlineFragment, FormattingAction, IntersectType, InlineToolName } from '@editorjs/model'; +import type { InlineTool as InlineToolVersion2 } from '@editorjs/editorjs'; +import type { InlineToolConstructable as InlineToolConstructableV2 } from '@editorjs/editorjs'; import type { InlineToolConstructorOptions as InlineToolConstructorOptionsVersion2 } from '@editorjs/editorjs'; /** @@ -11,7 +12,7 @@ export interface InlineToolConstructorOptions extends InlineToolConstructorOptio /** * Object represents formatting action with text range to be applied on */ -export interface FormattingActionWithRange { +export interface ToolFormattingOptions { /** * Formatting action - format or unformat */ @@ -23,12 +24,38 @@ export interface FormattingActionWithRange { range: TextRange; } +/** + * @todo support fakeSelectionRequired option + * Interface that represents options handled by toolbar element + */ +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, + + /** + * Oprions of custom toolbar behaviour + */ + toolbarOptions?: ToolbarOptions; +} + +export type InlineToolFormatData = Record; + /** * Inline Tool interface for version 3 * * In version 3, the save method is removed since all data is stored in the model */ -export interface InlineTool extends Omit { +export interface InlineTool extends Omit { /** * Type of merging of two ranges which intersect */ @@ -46,12 +73,18 @@ export interface InlineTool extends Omit void): ActionsElementWithOptions; } /**