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
}
/**