diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27cf4ecb0..5d37082a4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +### 2.25.0 + +- `New` — *Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool!
+Due to that API changes: tool's `toolbox` getter now can return either a single config item or an array of config items +- `New` — *Blocks API* — `composeBlockData()` method was added. + ### 2.24.4 - `Fix` — Keyboard selection by word [2045](https://github.com/codex-team/editor.js/issues/2045) diff --git a/docs/tools.md b/docs/tools.md index 85108cf8a..ec0dd7d7d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -56,7 +56,7 @@ Options that Tool can specify. All settings should be passed as static propertie | Name | Type | Default Value | Description | | -- | -- | -- | -- | -| `toolbox` | _Object_ | `undefined` | Pass here `icon` and `title` to display this `Tool` in the Editor's `Toolbox`
`icon` - HTML string with icon for Toolbox
`title` - optional title to display in Toolbox | +| `toolbox` | _Object_ | `undefined` | Pass the `icon` and the `title` there to display this `Tool` in the Editor's `Toolbox`
`icon` - HTML string with icon for the Toolbox
`title` - title to be displayed at the Toolbox.

May contain an array of `{icon, title, data}` to display the several variants of the tool, for example "Ordered list", "Unordered list". See details at [the documentation](https://editorjs.io/tools-api#toolbox) | | `enableLineBreaks` | _Boolean_ | `false` | With this option, Editor.js won't handle Enter keydowns. Can be helpful for Tools like `` where line breaks should be handled by default behaviour. | | `isInline` | _Boolean_ | `false` | Describes Tool as a [Tool for the Inline Toolbar](tools-inline.md) | | `isTune` | _Boolean_ | `false` | Describes Tool as a [Block Tune](block-tunes.md) | diff --git a/package.json b/package.json index d10c43866..ddc463442 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.24.3", + "version": "2.25.0", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editor.js", "types": "./types/index.d.ts", diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 9a9f98515..a9306c660 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -4,7 +4,8 @@ import { BlockToolData, BlockTune as IBlockTune, SanitizerConfig, - ToolConfig + ToolConfig, + ToolboxConfigEntry } from '../../../types'; import { SavedData } from '../../../types/data-formats'; @@ -734,6 +735,48 @@ export default class Block extends EventsDispatcher { } } + /** + * Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3") + * This method returns the entry that is related to the Block (depended on the Block data) + */ + public async getActiveToolboxEntry(): Promise { + const toolboxSettings = this.tool.toolbox; + + /** + * If Tool specifies just the single entry, treat it like an active + */ + if (toolboxSettings.length === 1) { + return Promise.resolve(this.tool.toolbox[0]); + } + + /** + * If we have several entries with their own data overrides, + * find those who matches some current data property + * + * Example: + * Tools' toolbox: [ + * {title: "Heading 1", data: {level: 1} }, + * {title: "Heading 2", data: {level: 2} } + * ] + * + * the Block data: { + * text: "Heading text", + * level: 2 + * } + * + * that means that for the current block, the second toolbox item (matched by "{level: 2}") is active + */ + const blockData = await this.data; + const toolboxItems = toolboxSettings; + + return toolboxItems.find((item) => { + return Object.entries(item.data) + .some(([propName, propValue]) => { + return blockData[propName] && _.equals(blockData[propName], propValue); + }); + }); + } + /** * Make default Block wrappers and put Tool`s content there * diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 994f72adc..8d742c850 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -3,6 +3,7 @@ import { BlockToolData, OutputData, ToolConfig } from '../../../../types'; import * as _ from './../../utils'; import BlockAPI from '../../block/api'; import Module from '../../__module'; +import Block from '../../block'; /** * @class BlocksAPI @@ -31,6 +32,7 @@ export default class BlocksAPI extends Module { insertNewBlock: (): void => this.insertNewBlock(), insert: this.insert, update: this.update, + composeBlockData: this.composeBlockData, }; } @@ -247,6 +249,24 @@ export default class BlocksAPI extends Module { return new BlockAPI(insertedBlock); } + /** + * Creates data of an empty block with a passed type. + * + * @param toolName - block tool name + */ + public composeBlockData = async (toolName: string): Promise => { + const tool = this.Editor.Tools.blockTools.get(toolName); + const block = new Block({ + tool, + api: this.Editor.API, + readOnly: true, + data: {}, + tunesData: {}, + }); + + return block.data; + } + /** * Insert new Block * After set caret to this Block diff --git a/src/components/modules/renderer.ts b/src/components/modules/renderer.ts index 9aad79ff8..6f800069b 100644 --- a/src/components/modules/renderer.ts +++ b/src/components/modules/renderer.ts @@ -100,8 +100,9 @@ export default class Renderer extends Module { if (Tools.unavailable.has(tool)) { const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox; + const toolboxTitle = toolboxSettings[0]?.title; - stubData.title = toolboxSettings?.title || stubData.title; + stubData.title = toolboxTitle || stubData.title; } const stub = BlockManager.insert({ diff --git a/src/components/modules/toolbar/conversion.ts b/src/components/modules/toolbar/conversion.ts index f53d0ec5b..30f1bd7dd 100644 --- a/src/components/modules/toolbar/conversion.ts +++ b/src/components/modules/toolbar/conversion.ts @@ -6,6 +6,7 @@ import Flipper from '../../flipper'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import { clean } from '../../utils/sanitizer'; +import { ToolboxConfigEntry, BlockToolData } from '../../../../types'; /** * HTML Elements used for ConversionToolbar @@ -47,9 +48,9 @@ export default class ConversionToolbar extends Module { public opened = false; /** - * Available tools + * Available tools data */ - private tools: { [key: string]: HTMLElement } = {}; + private tools: {name: string; toolboxItem: ToolboxConfigEntry; button: HTMLElement}[] = [] /** * Instance of class that responses for leafing buttons by arrows/tab @@ -135,19 +136,18 @@ export default class ConversionToolbar extends Module { this.nodes.wrapper.classList.add(ConversionToolbar.CSS.conversionToolbarShowed); /** - * We use timeout to prevent bubbling Enter keydown on first dropdown item + * We use RAF to prevent bubbling Enter keydown on first dropdown item * Conversion flipper will be activated after dropdown will open */ - setTimeout(() => { - this.flipper.activate(Object.values(this.tools).filter((button) => { + window.requestAnimationFrame(() => { + this.flipper.activate(this.tools.map(tool => tool.button).filter((button) => { return !button.classList.contains(ConversionToolbar.CSS.conversionToolHidden); })); this.flipper.focusFirst(); - if (_.isFunction(this.togglingCallback)) { this.togglingCallback(true); } - }, 50); + }); } /** @@ -167,9 +167,11 @@ export default class ConversionToolbar extends Module { * Returns true if it has more than one tool available for convert in */ public hasTools(): boolean { - const tools = Object.keys(this.tools); // available tools in array representation + if (this.tools.length === 1) { + return this.tools[0].name !== this.config.defaultBlock; + } - return !(tools.length === 1 && tools.shift() === this.config.defaultBlock); + return true; } /** @@ -177,26 +179,18 @@ export default class ConversionToolbar extends Module { * For that Tools must provide import/export methods * * @param {string} replacingToolName - name of Tool which replaces current + * @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified */ - public async replaceWithBlock(replacingToolName: string): Promise { + public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise { /** * At first, we get current Block data * * @type {BlockToolConstructable} */ const currentBlockTool = this.Editor.BlockManager.currentBlock.tool; - const currentBlockName = this.Editor.BlockManager.currentBlock.name; const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData; const blockData = savedBlock.data; - /** - * When current Block name is equals to the replacing tool Name, - * than convert this Block back to the default Block - */ - if (currentBlockName === replacingToolName) { - replacingToolName = this.config.defaultBlock; - } - /** * Getting a class of replacing Tool * @@ -252,6 +246,14 @@ export default class ConversionToolbar extends Module { return; } + /** + * If this conversion fired by the one of multiple Toolbox items, + * extend converted data with this item's "data" overrides + */ + if (blockDataOverrides) { + newBlockData = Object.assign(newBlockData, blockDataOverrides); + } + this.Editor.BlockManager.replace({ tool: replacingToolName, data: newBlockData, @@ -276,64 +278,93 @@ export default class ConversionToolbar extends Module { Array .from(tools.entries()) .forEach(([name, tool]) => { - const toolboxSettings = tool.toolbox; const conversionConfig = tool.conversionConfig; - /** - * Skip tools that don't pass 'toolbox' property - */ - if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) { - return; - } - /** * Skip tools without «import» rule specified */ if (!conversionConfig || !conversionConfig.import) { return; } - - this.addTool(name, toolboxSettings.icon, toolboxSettings.title); + tool.toolbox.forEach((toolboxItem) => + this.addToolIfValid(name, toolboxItem) + ); }); } + /** + * Inserts a tool to the ConversionToolbar if the tool's toolbox config is valid + * + * @param name - tool's name + * @param toolboxSettings - tool's single toolbox setting + */ + private addToolIfValid(name: string, toolboxSettings: ToolboxConfigEntry): void { + /** + * Skip tools that don't pass 'toolbox' property + */ + if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) { + return; + } + + this.addTool(name, toolboxSettings); + } + /** * Add tool to the Conversion Toolbar * - * @param {string} toolName - name of Tool to add - * @param {string} toolIcon - Tool icon - * @param {string} title - button title + * @param toolName - name of Tool to add + * @param toolboxItem - tool's toolbox item data */ - private addTool(toolName: string, toolIcon: string, title: string): void { + private addTool(toolName: string, toolboxItem: ToolboxConfigEntry): void { const tool = $.make('div', [ ConversionToolbar.CSS.conversionTool ]); const icon = $.make('div', [ ConversionToolbar.CSS.conversionToolIcon ]); tool.dataset.tool = toolName; - icon.innerHTML = toolIcon; + icon.innerHTML = toolboxItem.icon; $.append(tool, icon); - $.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, title || _.capitalize(toolName)))); + $.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName)))); $.append(this.nodes.tools, tool); - this.tools[toolName] = tool; + this.tools.push({ + name: toolName, + button: tool, + toolboxItem: toolboxItem, + }); this.listeners.on(tool, 'click', async () => { - await this.replaceWithBlock(toolName); + await this.replaceWithBlock(toolName, toolboxItem.data); }); } /** * Hide current Tool and show others */ - private filterTools(): void { + private async filterTools(): Promise { const { currentBlock } = this.Editor.BlockManager; + const currentBlockActiveToolboxEntry = await currentBlock.getActiveToolboxEntry(); /** - * Show previously hided + * Compares two Toolbox entries + * + * @param entry1 - entry to compare + * @param entry2 - entry to compare with */ - Object.entries(this.tools).forEach(([name, button]) => { - button.hidden = false; - button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, name === currentBlock.name); + function isTheSameToolboxEntry(entry1, entry2): boolean { + return entry1.icon === entry2.icon && entry1.title === entry2.title; + } + + this.tools.forEach(tool => { + let hidden = false; + + if (currentBlockActiveToolboxEntry) { + const isToolboxItemActive = isTheSameToolboxEntry(currentBlockActiveToolboxEntry, tool.toolboxItem); + + hidden = (tool.button.dataset.tool === currentBlock.name && isToolboxItemActive); + } + + tool.button.hidden = hidden; + tool.button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, hidden); }); } diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 799b60fd5..1a4b6d061 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -463,7 +463,7 @@ export default class InlineToolbar extends Module { /** * Changes Conversion Dropdown content for current block's Tool */ - private setConversionTogglerContent(): void { + private async setConversionTogglerContent(): Promise { const { BlockManager } = this.Editor; const { currentBlock } = BlockManager; const toolName = currentBlock.name; @@ -480,7 +480,7 @@ export default class InlineToolbar extends Module { /** * Get icon or title for dropdown */ - const toolboxSettings = currentBlock.tool.toolbox || {}; + const toolboxSettings = await currentBlock.getActiveToolboxEntry() || {}; this.nodes.conversionTogglerContent.innerHTML = toolboxSettings.icon || diff --git a/src/components/tools/block.ts b/src/components/tools/block.ts index e410851d9..ff1592b26 100644 --- a/src/components/tools/block.ts +++ b/src/components/tools/block.ts @@ -5,8 +5,8 @@ import { BlockToolConstructable, BlockToolData, ConversionConfig, - PasteConfig, SanitizerConfig, - ToolboxConfig + PasteConfig, SanitizerConfig, ToolboxConfig, + ToolboxConfigEntry } from '../../../types'; import * as _ from '../utils'; import InlineTool from './inline'; @@ -70,21 +70,67 @@ export default class BlockTool extends BaseTool { } /** - * Returns Tool toolbox configuration (internal or user-specified) + * 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(): ToolboxConfig { + 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 ?? toolToolboxSettings) === false) { + if (userToolboxSettings === false) { return; } + /** + * Return tool's toolbox settings if user settings are not defined + */ + if (!userToolboxSettings) { + return Array.isArray(toolToolboxSettings) ? toolToolboxSettings : [ toolToolboxSettings ]; + } - return Object.assign({}, toolToolboxSettings, userToolboxSettings); + /** + * 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) { + return { + ...toolToolboxEntry, + ...item, + }; + } + + return item; + }); + } + + return [ userToolboxSettings ]; + } else { + if (Array.isArray(userToolboxSettings)) { + return userToolboxSettings; + } + + return [ + { + ...toolToolboxSettings, + ...userToolboxSettings, + }, + ]; + } } /** diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index b781aa286..7ae48c58f 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -3,9 +3,9 @@ import { BlockToolAPI } from '../block'; import Shortcuts from '../utils/shortcuts'; import BlockTool from '../tools/block'; import ToolsCollection from '../tools/collection'; -import { API } from '../../../types'; +import { API, BlockToolData, ToolboxConfigEntry } from '../../../types'; import EventsDispatcher from '../utils/events'; -import Popover, { PopoverEvent } from '../utils/popover'; +import Popover, { PopoverEvent, PopoverItem } from '../utils/popover'; import I18n from '../i18n'; import { I18nInternalNS } from '../i18n/namespace-internal'; @@ -132,17 +132,7 @@ export default class Toolbox extends EventsDispatcher { searchable: true, filterLabel: this.i18nLabels.filter, nothingFoundLabel: this.i18nLabels.nothingFound, - items: this.toolsToBeDisplayed.map(tool => { - return { - icon: tool.toolbox.icon, - label: I18n.t(I18nInternalNS.toolNames, tool.toolbox.title || _.capitalize(tool.name)), - name: tool.name, - onClick: (item): void => { - this.toolButtonActivated(tool.name); - }, - secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '', - }; - }), + items: this.toolboxItemsToBeDisplayed, }); this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked); @@ -185,9 +175,10 @@ export default class Toolbox extends EventsDispatcher { * Toolbox Tool's button click handler * * @param toolName - tool type to be activated + * @param blockDataOverrides - Block data predefined by the activated Toolbox item */ - public toolButtonActivated(toolName: string): void { - this.insertNewBlock(toolName); + public toolButtonActivated(toolName: string, blockDataOverrides: BlockToolData): void { + this.insertNewBlock(toolName, blockDataOverrides); } /** @@ -262,24 +253,79 @@ export default class Toolbox extends EventsDispatcher { private get toolsToBeDisplayed(): BlockTool[] { return Array .from(this.tools.values()) - .filter(tool => { + .reduce((result, tool) => { const toolToolboxSettings = tool.toolbox; - /** - * Skip tools that don't pass 'toolbox' property - */ - if (!toolToolboxSettings) { - return false; + if (toolToolboxSettings) { + const validToolboxSettings = toolToolboxSettings.filter(item => { + return this.areToolboxSettingsValid(item, tool.name); + }); + + result.push({ + ...tool, + toolbox: validToolboxSettings, + }); } - if (toolToolboxSettings && !toolToolboxSettings.icon) { - _.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name); + return result; + }, []); + } - return false; + /** + * Returns list of items that will be displayed in toolbox + */ + @_.cacheable + private get toolboxItemsToBeDisplayed(): PopoverItem[] { + /** + * Maps tool data to popover item structure + */ + const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItem => { + return { + icon: toolboxItem.icon, + label: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)), + name: tool.name, + onClick: (e): void => { + this.toolButtonActivated(tool.name, toolboxItem.data); + }, + secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '', + }; + }; + + return this.toolsToBeDisplayed + .reduce((result, tool) => { + if (Array.isArray(tool.toolbox)) { + tool.toolbox.forEach(item => { + result.push(toPopoverItem(item, tool)); + }); + } else { + result.push(toPopoverItem(tool.toolbox, tool)); } - return true; - }); + return result; + }, []); + } + + /** + * Validates tool's toolbox settings + * + * @param toolToolboxSettings - item to validate + * @param toolName - name of the tool used in console warning if item is not valid + */ + private areToolboxSettingsValid(toolToolboxSettings: ToolboxConfigEntry, toolName: string): boolean { + /** + * Skip tools that don't pass 'toolbox' property + */ + if (!toolToolboxSettings) { + return false; + } + + if (toolToolboxSettings && !toolToolboxSettings.icon) { + _.log('Toolbar icon is missed. Tool %o skipped', 'warn', toolName); + + return false; + } + + return true; } /** @@ -331,8 +377,9 @@ export default class Toolbox extends EventsDispatcher { * Can be called when button clicked on Toolbox or by ShortcutData * * @param {string} toolName - Tool name + * @param blockDataOverrides - predefined Block data */ - private insertNewBlock(toolName: string): void { + private async insertNewBlock(toolName: string, blockDataOverrides?: BlockToolData): Promise { const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex); @@ -346,9 +393,20 @@ export default class Toolbox extends EventsDispatcher { */ const index = currentBlock.isEmpty ? currentBlockIndex : currentBlockIndex + 1; + let blockData; + + if (blockDataOverrides) { + /** + * Merge real tool's data with data overrides + */ + const defaultBlockData = await this.api.blocks.composeBlockData(toolName); + + blockData = Object.assign(defaultBlockData, blockDataOverrides); + } + const newBlock = this.api.blocks.insert( toolName, - undefined, + blockData, undefined, index, undefined, diff --git a/src/components/utils.ts b/src/components/utils.ts index 2f1650bf8..f8e2fe0be 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -778,4 +778,22 @@ export const isIosDevice = window.navigator && window.navigator.platform && (/iP(ad|hone|od)/.test(window.navigator.platform) || - (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1)); \ No newline at end of file + (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1)); + +/** + * Compares two values with unknown type + * + * @param var1 - value to compare + * @param var2 - value to compare with + * @returns {boolean} true if they are equal + */ +export function equals(var1: unknown, var2: unknown): boolean { + const isVar1NonPrimitive = Array.isArray(var1) || isObject(var1); + const isVar2NonPrimitive = Array.isArray(var2) || isObject(var2); + + if (isVar1NonPrimitive || isVar2NonPrimitive) { + return JSON.stringify(var1) === JSON.stringify(var2); + } + + return var1 === var2; +} diff --git a/test/cypress/tests/api/tools.spec.ts b/test/cypress/tests/api/tools.spec.ts new file mode 100644 index 000000000..e85d74582 --- /dev/null +++ b/test/cypress/tests/api/tools.spec.ts @@ -0,0 +1,269 @@ +import { ToolboxConfig, BlockToolData, ToolboxConfigEntry } from '../../../../types'; + +const ICON = ''; + +describe('Editor Tools Api', () => { + context('Toolbox', () => { + it('should render a toolbox entry for tool if configured', () => { + /** + * Tool with single toolbox entry configured + */ + class TestTool { + /** + * Returns toolbox config as list of entries + */ + public static get toolbox(): ToolboxConfigEntry { + return { + title: 'Entry 1', + icon: ICON, + }; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .should('have.length', 1); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool] .ce-popover__item-icon') + .should('contain.html', TestTool.toolbox.icon); + }); + + it('should render several toolbox entries for one tool if configured', () => { + /** + * Tool with several toolbox entries configured + */ + class TestTool { + /** + * Returns toolbox config as list of entries + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Entry 1', + icon: ICON, + }, + { + title: 'Entry 2', + icon: ICON, + }, + ]; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .should('have.length', 2); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .first() + .should('contain.text', TestTool.toolbox[0].title); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .last() + .should('contain.text', TestTool.toolbox[1].title); + }); + + it('should insert block with overriden data on entry click in case toolbox entry provides data overrides', () => { + const text = 'Text'; + const dataOverrides = { + testProp: 'new value', + }; + + /** + * Tool with default data to be overriden + */ + class TestTool { + private _data = { + testProp: 'default value', + } + + /** + * Tool contructor + * + * @param data - previously saved data + */ + constructor({ data }) { + this._data = data; + } + + /** + * Returns toolbox config as list of entries with overriden data + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Entry 1', + icon: ICON, + data: dataOverrides, + }, + ]; + } + + /** + * Return Tool's view + */ + public render(): HTMLElement { + const wrapper = document.createElement('div'); + + wrapper.setAttribute('contenteditable', 'true'); + + return wrapper; + } + + /** + * Extracts Tool's data from the view + * + * @param el - tool view + */ + public save(el: HTMLElement): BlockToolData { + return { + ...this._data, + text: el.innerHTML, + }; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .last() + .click() + .type(text); + + cy.get('@editorInstance') + .then(async (editor: any) => { + const editorData = await editor.save(); + + expect(editorData.blocks[0].data).to.be.deep.eq({ + ...dataOverrides, + text, + }); + }); + }); + + it('should not display tool in toolbox if the tool has single toolbox entry configured and it has icon missing', () => { + /** + * Tool with one of the toolbox entries with icon missing + */ + class TestTool { + /** + * Returns toolbox config as list of entries one of which has missing icon + */ + public static get toolbox(): ToolboxConfig { + return { + title: 'Entry 2', + }; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .should('not.exist'); + }); + + it('should skip toolbox entries that have no icon', () => { + const skippedEntryTitle = 'Entry 2'; + + /** + * Tool with one of the toolbox entries with icon missing + */ + class TestTool { + /** + * Returns toolbox config as list of entries one of which has missing icon + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Entry 1', + icon: ICON, + }, + { + title: skippedEntryTitle, + }, + ]; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .should('have.length', 1) + .should('not.contain', skippedEntryTitle); + }); + }); +}); \ No newline at end of file diff --git a/test/cypress/tests/i18n.spec.ts b/test/cypress/tests/i18n.spec.ts index 2f31d48c7..8f8fe7439 100644 --- a/test/cypress/tests/i18n.spec.ts +++ b/test/cypress/tests/i18n.spec.ts @@ -1,21 +1,6 @@ import Header from '@editorjs/header'; import { ToolboxConfig } from '../../../types'; -/** - * Tool class allowing to test case when capitalized tool name is used as translation key if toolbox title is missing - */ -class TestTool { - /** - * Returns toolbox config without title - */ - public static get toolbox(): ToolboxConfig { - return { - title: '', - icon: '', - }; - } -} - describe('Editor i18n', () => { context('Toolbox', () => { it('should translate tool title in a toolbox', () => { @@ -50,10 +35,85 @@ describe('Editor i18n', () => { .should('contain.text', toolNamesDictionary.Heading); }); + it('should translate titles of toolbox entries', () => { + if (this && this.editorInstance) { + this.editorInstance.destroy(); + } + const toolNamesDictionary = { + Title1: 'Название 1', + Title2: 'Название 2', + }; + + /** + * Tool with several toolbox entries configured + */ + class TestTool { + /** + * Returns toolbox config as list of entries + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Title1', + icon: 'Icon 1', + }, + { + title: 'Title2', + icon: 'Icon 2', + }, + ]; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .first() + .should('contain.text', toolNamesDictionary.Title1); + + cy.get('[data-cy=editorjs]') + .get('div.ce-popover__item[data-item-name=testTool]') + .last() + .should('contain.text', toolNamesDictionary.Title2); + }); + it('should use capitalized tool name as translation key if toolbox title is missing', () => { if (this && this.editorInstance) { this.editorInstance.destroy(); } + + /** + * Tool class allowing to test case when capitalized tool name is used as translation key if toolbox title is missing + */ + class TestTool { + /** + * Returns toolbox config without title + */ + public static get toolbox(): ToolboxConfig { + return { + title: '', + icon: '', + }; + } + } const toolNamesDictionary = { TestTool: 'ТестТул', }; diff --git a/test/cypress/tests/tools/BlockTool.spec.ts b/test/cypress/tests/tools/BlockTool.spec.ts index 114b820e6..bf47b75d6 100644 --- a/test/cypress/tests/tools/BlockTool.spec.ts +++ b/test/cypress/tests/tools/BlockTool.spec.ts @@ -351,13 +351,13 @@ describe('BlockTool', () => { }); context('.toolbox', () => { - it('should return user provided toolbox config', () => { + it('should return user provided toolbox config wrapped in array', () => { const tool = new BlockTool(options as any); - expect(tool.toolbox).to.be.deep.eq(options.config.toolbox); + expect(tool.toolbox).to.be.deep.eq([ options.config.toolbox ]); }); - it('should return Tool provided toolbox config if user one is not specified', () => { + it('should return Tool provided toolbox config wrapped in array if user one is not specified', () => { const tool = new BlockTool({ ...options, config: { @@ -366,10 +366,10 @@ describe('BlockTool', () => { }, } as any); - expect(tool.toolbox).to.be.deep.eq(options.constructable.toolbox); + expect(tool.toolbox).to.be.deep.eq([ options.constructable.toolbox ]); }); - it('should merge Tool provided toolbox config and user one', () => { + it('should merge Tool provided toolbox config and user one and wrap result in array in case both are objects', () => { const tool1 = new BlockTool({ ...options, config: { @@ -389,8 +389,101 @@ describe('BlockTool', () => { }, } as any); - expect(tool1.toolbox).to.be.deep.eq(Object.assign({}, options.constructable.toolbox, { title: options.config.toolbox.title })); - expect(tool2.toolbox).to.be.deep.eq(Object.assign({}, options.constructable.toolbox, { icon: options.config.toolbox.icon })); + expect(tool1.toolbox).to.be.deep.eq([ Object.assign({}, options.constructable.toolbox, { title: options.config.toolbox.title }) ]); + expect(tool2.toolbox).to.be.deep.eq([ Object.assign({}, options.constructable.toolbox, { icon: options.config.toolbox.icon }) ]); + }); + + it('should replace Tool provided toolbox config with user defined config in case the first is an array and the second is an object', () => { + const toolboxEntries = [ + { + title: 'Toolbox entry 1', + }, + { + title: 'Toolbox entry 2', + }, + ]; + const userDefinedToolboxConfig = { + icon: options.config.toolbox.icon, + title: options.config.toolbox.title, + }; + const tool = new BlockTool({ + ...options, + constructable: { + ...options.constructable, + toolbox: toolboxEntries, + }, + config: { + ...options.config, + toolbox: userDefinedToolboxConfig, + }, + } as any); + + expect(tool.toolbox).to.be.deep.eq([ userDefinedToolboxConfig ]); + }); + + it('should replace Tool provided toolbox config with user defined config in case the first is an object and the second is an array', () => { + const userDefinedToolboxConfig = [ + { + title: 'Toolbox entry 1', + }, + { + title: 'Toolbox entry 2', + }, + ]; + const tool = new BlockTool({ + ...options, + config: { + ...options.config, + toolbox: userDefinedToolboxConfig, + }, + } as any); + + expect(tool.toolbox).to.be.deep.eq(userDefinedToolboxConfig); + }); + + it('should merge Tool provided toolbox config with user defined config in case both are arrays', () => { + const toolboxEntries = [ + { + title: 'Toolbox entry 1', + }, + ]; + + const userDefinedToolboxConfig = [ + { + icon: 'Icon 1', + }, + { + icon: 'Icon 2', + title: 'Toolbox entry 2', + }, + ]; + + const tool = new BlockTool({ + ...options, + constructable: { + ...options.constructable, + toolbox: toolboxEntries, + }, + config: { + ...options.config, + toolbox: userDefinedToolboxConfig, + }, + } as any); + + const expected = userDefinedToolboxConfig.map((item, i) => { + const toolToolboxEntry = toolboxEntries[i]; + + if (toolToolboxEntry) { + return { + ...toolToolboxEntry, + ...item, + }; + } + + return item; + }); + + expect(tool.toolbox).to.be.deep.eq(expected); }); it('should return undefined if user specifies false as a value', () => { diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index bd7ca41d9..21649db8a 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -113,6 +113,13 @@ export interface Blocks { ): BlockAPI; + /** + * Creates data of an empty block with a passed type. + * + * @param toolName - block tool name + */ + composeBlockData(toolName: string): Promise + /** * Updates block data by id * diff --git a/types/index.d.ts b/types/index.d.ts index a7be95d22..f9deaf8c6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -48,6 +48,7 @@ export { Tool, ToolConstructable, ToolboxConfig, + ToolboxConfigEntry, ToolSettings, ToolConfig, PasteEvent, diff --git a/types/tools/block-tool.d.ts b/types/tools/block-tool.d.ts index 3b109de6f..afc2a0ede 100644 --- a/types/tools/block-tool.d.ts +++ b/types/tools/block-tool.d.ts @@ -1,8 +1,8 @@ import { ConversionConfig, PasteConfig, SanitizerConfig } from '../configs'; import { BlockToolData } from './block-tool-data'; -import {BaseTool, BaseToolConstructable} from './tool'; +import { BaseTool, BaseToolConstructable } from './tool'; import { ToolConfig } from './tool-config'; -import {API, BlockAPI} from '../index'; +import { API, BlockAPI, ToolboxConfig } from '../index'; import { PasteEvent } from './paste-events'; import { MoveEvent } from './hook-events'; @@ -95,17 +95,7 @@ export interface BlockToolConstructable extends BaseToolConstructable { /** * Tool's Toolbox settings */ - toolbox?: { - /** - * HTML string with an icon for Toolbox - */ - icon: string; - - /** - * Tool title for Toolbox - */ - title?: string; - }; + toolbox?: ToolboxConfig; /** * Paste substitutions configuration diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index f093d9691..c6d61cdf5 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -1,10 +1,16 @@ -import {ToolConfig} from './tool-config'; -import {ToolConstructable} from './index'; +import { ToolConfig } from './tool-config'; +import { ToolConstructable, BlockToolData } from './index'; + +/** + * Tool may specify its toolbox configuration + * It may include several entries as well + */ +export type ToolboxConfig = ToolboxConfigEntry | ToolboxConfigEntry[]; /** * Tool's Toolbox settings */ -export interface ToolboxConfig { +export interface ToolboxConfigEntry { /** * Tool title for Toolbox */ @@ -14,6 +20,11 @@ export interface ToolboxConfig { * HTML string with an icon for Toolbox */ icon?: string; + + /** + * May contain overrides for tool default config + */ + data?: BlockToolData } /**