Merge branch 'next' into fix-link-safari

This commit is contained in:
Peter Savchenko 2024-10-28 19:58:23 +03:00
commit be1cc0d7e1
29 changed files with 676 additions and 166 deletions

View file

@ -1,5 +1,19 @@
# Changelog
### 2.31.0
- `New` - Inline tools (those with `isReadOnlySupported` specified) can now be used in read-only mode
- `Improvement` - Block manager passes target tool config to the `conversionConfig.import` method on conversion
- `Fix` - Fix selection of first block in read-only initialization with "autofocus=true"
- `Fix` - Incorrect caret position after blocks merging in Safari
- `Fix` - Several toolbox items exported by the one tool have the same shortcut displayed in toolbox
### 2.30.6
- `Fix` Fix the display of Convert To near blocks that do not have the conversionConfig.export rule specified
- `Fix` The Plus button does not appear when the editor is loaded in an iframe in Chrome
- `Fix` - Prevent inline toolbar from closing in nested instance of editor
### 2.30.5
`Fix` Fix exported types
@ -27,7 +41,7 @@
- `New` Block Tunes now supports nesting items
- `New` Block Tunes now supports separator items
- `New` *Menu Config* New item type HTML
`New` *Menu Config* Default and HTML items now support hints
- `New` *Menu Config* Default and HTML items now support hints
- `New` Inline Toolbar has new look 💅
- `New` Inline Tool's `render()` now supports [Menu Config](https://editorjs.io/menu-config/) format
- `New` *ToolsAPI* All installed block tools now accessible via ToolsAPI `getBlockTools()` method
@ -36,11 +50,11 @@
- `New` "Convert to" control is now also available in Block Tunes
- `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current.
- `Improvement` — Now Paragraph placeholder will be shown for the current paragraph, not only the first one.
- `Improvment` - The API `blocks.update` now accepts `tunes` data as optional third argument and makes `data` - block data as optional.
- `Improvement` - The API `blocks.update` now accepts `tunes` data as optional third argument and makes `data` - block data as optional.
- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig)
- `Improvement` - The API `blocks.convert()` now returns the new block API
- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id
- `Impovement` *MenuConfig* `TunesMenuConfig` type is deprecated, use the `MenuConfig` instead
- `Improvement` *MenuConfig* `TunesMenuConfig` type is deprecated, use the `MenuConfig` instead
`Improvement`*Types*`BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore
- `Improvement` - The Plus button and Block Tunes toggler are now better aligned with large line-height blocks, such as Headings
- `Improvement` — Creating links on Android devices: now the mobile keyboard will have an "Enter" key for accepting the inserted link.

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.30.5",
"version": "2.31.0-rc.2",
"description": "Editor.js — open source block-style WYSIWYG editor with JSON output",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
@ -45,7 +45,7 @@
"@cypress/code-coverage": "^3.10.3",
"@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.8.7",
"@editorjs/header": "^2.8.8",
"@editorjs/paragraph": "^2.11.6",
"@editorjs/simple-image": "^1.4.1",
"@types/node": "^18.15.11",
@ -53,7 +53,7 @@
"codex-notifier": "^1.1.2",
"codex-tooltip": "^1.0.5",
"core-js": "3.30.0",
"cypress": "^13.7.1",
"cypress": "^13.13.3",
"cypress-intellij-reporter": "^0.0.7",
"cypress-plugin-tab": "^1.0.5",
"cypress-terminal-report": "^5.3.2",
@ -77,5 +77,8 @@
"collective": {
"type": "opencollective",
"url": "https://opencollective.com/editorjs"
},
"dependencies": {
"@editorjs/caret": "^1.0.1"
}
}

View file

@ -61,7 +61,7 @@ export default class Core {
UI.checkEmptiness();
ModificationsObserver.enable();
if ((this.configuration as EditorConfig).autofocus) {
if ((this.configuration as EditorConfig).autofocus === true && this.configuration.readOnly !== true) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
}

View file

@ -8,6 +8,7 @@ import Flipper from '../flipper';
import type Block from '../block';
import { areBlocksMergeable } from '../utils/blocks';
import * as caretUtils from '../utils/caret';
import { focus } from '@editorjs/caret';
/**
*
@ -506,15 +507,17 @@ export default class BlockEvents extends Module {
* @param blockToMerge - what Block we want to merge
*/
private mergeBlocks(targetBlock: Block, blockToMerge: Block): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
const { BlockManager, Toolbar } = this.Editor;
Caret.createShadow(targetBlock.lastInput);
if (targetBlock.lastInput === undefined) {
return;
}
focus(targetBlock.lastInput, false);
BlockManager
.mergeBlocks(targetBlock, blockToMerge)
.then(() => {
/** Restore caret position after merge */
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
Toolbar.close();
});
}

View file

@ -684,7 +684,7 @@ export default class BlockManager extends Module {
*
* @param {Node} element - html element to get Block by
*/
public getBlock(element: HTMLElement): Block {
public getBlock(element: HTMLElement): Block | undefined {
if (!$.isElement(element) as boolean) {
element = element.parentNode as HTMLElement;
}
@ -863,7 +863,7 @@ export default class BlockManager extends Module {
/**
* Now using Conversion Config "import" we compose a new Block data
*/
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig);
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig, replacingTool.settings);
/**
* Optional data overrides.

View file

@ -12,6 +12,7 @@ import { CommonInternalSettings } from '../../tools/base';
import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover';
import { PopoverItemType } from '../../utils/popover';
import { PopoverInline } from '../../utils/popover/popover-inline';
import type InlineToolAdapter from 'src/components/tools/inline';
/**
* Inline Toolbar elements
@ -54,7 +55,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Currently visible tools instances
*/
private toolsInstances: Map<string, IInlineTool> | null = new Map();
private tools: Map<InlineToolAdapter, IInlineTool> = new Map();
/**
* @param moduleConfiguration - Module Configuration
@ -66,21 +67,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
config,
eventsDispatcher,
});
}
/**
* Toggles read-only mode
*
* @param {boolean} readOnlyEnabled - read-only mode
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) {
window.requestIdleCallback(() => {
this.make();
}, { timeout: 2000 });
} else {
this.destroy();
}
window.requestIdleCallback(() => {
this.make();
}, { timeout: 2000 });
}
/**
@ -126,14 +116,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return;
}
if (this.Editor.ReadOnly.isEnabled) {
return;
}
for (const [tool, toolInstance] of this.tools) {
const shortcut = this.getToolShortcut(tool.name);
Array.from(this.toolsInstances.entries()).forEach(([name, toolInstance]) => {
const shortcut = this.getToolShortcut(name);
if (shortcut) {
if (shortcut !== undefined) {
Shortcuts.remove(this.Editor.UI.nodes.redactor, shortcut);
}
@ -143,9 +129,9 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
if (_.isFunction(toolInstance.clear)) {
toolInstance.clear();
}
});
}
this.toolsInstances = null;
this.tools = new Map();
this.reset();
this.opened = false;
@ -216,10 +202,12 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.popover.destroy();
}
const inlineTools = await this.getInlineTools();
this.createToolsInstances();
const popoverItems = await this.getPopoverItems();
this.popover = new PopoverInline({
items: inlineTools,
items: popoverItems,
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@ -302,25 +290,36 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return false;
}
if (currentSelection && tagsConflictsWithSelection.includes(target.tagName)) {
if (currentSelection !== null && tagsConflictsWithSelection.includes(target.tagName)) {
return false;
}
// The selection of the element only in contenteditable
const contenteditable = target.closest('[contenteditable="true"]');
if (contenteditable === null) {
return false;
}
// is enabled by current Block's Tool
/**
* Check if there is at leas one tool enabled by current Block's Tool
*/
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
if (!currentBlock) {
return false;
}
return currentBlock.tool.inlineTools.size !== 0;
/**
* Check that at least one tool is available for the current block
*/
const toolsAvailable = this.getTools();
const isAtLeastOneToolAvailable = toolsAvailable.some((tool) => currentBlock.tool.inlineTools.has(tool.name));
if (isAtLeastOneToolAvailable === false) {
return false;
}
/**
* Inline toolbar will be shown only if the target is contenteditable
* In Read-Only mode, the target should be contenteditable with "false" value
*/
const contenteditable = target.closest('[contenteditable]');
return contenteditable !== null;
}
/**
@ -329,32 +328,63 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
/**
* Returns Inline Tools segregated by their appearance type: popover items and custom html elements.
* Sets this.toolsInstances map
* Returns tools that are available for current block
*
* Used to check if Inline Toolbar could be shown
* and to render tools in the Inline Toolbar
*/
private async getInlineTools(): Promise<PopoverItemParams[]> {
const currentSelection = SelectionUtils.get();
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
private getTools(): InlineToolAdapter[] {
const currentBlock = this.Editor.BlockManager.currentBlock;
if (!currentBlock) {
return [];
}
const inlineTools = Array.from(currentBlock.tool.inlineTools.values());
return inlineTools.filter((tool) => {
/**
* We support inline tools in read only mode.
* Such tools should have isReadOnlySupported flag set to true
*/
if (this.Editor.ReadOnly.isEnabled && tool.isReadOnlySupported !== true) {
return false;
}
return true;
});
}
/**
* Constructs tools instances and saves them to this.tools
*/
private createToolsInstances(): void {
this.tools = new Map();
const tools = this.getTools();
tools.forEach((tool) => {
const instance = tool.create();
this.tools.set(tool, instance);
});
}
/**
* Returns Popover Items for tools segregated by their appearance type: regular items and custom html elements.
*/
private async getPopoverItems(): Promise<PopoverItemParams[]> {
const popoverItems = [] as PopoverItemParams[];
if (this.toolsInstances === null) {
this.toolsInstances = new Map();
}
let i = 0;
for (let i = 0; i < inlineTools.length; i++) {
const tool = inlineTools[i];
const instance = tool.create();
for (const [tool, instance] of this.tools) {
const renderedTool = await instance.render();
this.toolsInstances.set(tool.name, instance);
/** Enable tool shortcut */
const shortcut = this.getToolShortcut(tool.name);
if (shortcut) {
if (shortcut !== undefined) {
try {
this.enableShortcuts(tool.name, shortcut);
} catch (e) {}
@ -441,7 +471,9 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
type: PopoverItemType.Default,
} as PopoverItemParams;
/** Prepend with separator if item has children and not the first one */
/**
* Prepend the separator if item has children and not the first one
*/
if ('children' in popoverItem && i !== 0) {
popoverItems.push({
type: PopoverItemType.Separator,
@ -450,14 +482,18 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
popoverItems.push(popoverItem);
/** Append separator after the item is it has children and not the last one */
if ('children' in popoverItem && i < inlineTools.length - 1) {
/**
* Append a separator after the item if it has children and not the last one
*/
if ('children' in popoverItem && i < this.tools.size - 1) {
popoverItems.push({
type: PopoverItemType.Separator,
});
}
}
});
i++;
}
return popoverItems;
@ -545,7 +581,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Check Tools` state by selection
*/
private checkToolsState(): void {
this.toolsInstances?.forEach((toolInstance) => {
this.tools?.forEach((toolInstance) => {
toolInstance.checkState?.(SelectionUtils.get());
});
}

View file

@ -68,7 +68,7 @@ export default class UI extends Module<UINodes> {
* @returns {DOMRect}
*/
public get contentRect(): DOMRect {
if (this.contentRectCache) {
if (this.contentRectCache !== null) {
return this.contentRectCache;
}
@ -85,7 +85,7 @@ export default class UI extends Module<UINodes> {
} as DOMRect;
}
this.contentRectCache = someBlock.getBoundingClientRect() as DOMRect;
this.contentRectCache = someBlock.getBoundingClientRect();
return this.contentRectCache;
}
@ -104,7 +104,7 @@ export default class UI extends Module<UINodes> {
*
* @type {DOMRect}
*/
private contentRectCache: DOMRect = undefined;
private contentRectCache: DOMRect | null = null;
/**
* Handle window resize only when it finished
@ -116,6 +116,13 @@ export default class UI extends Module<UINodes> {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 200);
/**
* Handle selection change to manipulate Inline Toolbar appearance
*/
private selectionChangeDebounced = _.debounce(() => {
this.selectionChanged();
}, selectionChangeDebounceTimeout);
/**
* Making main interface
*/
@ -160,7 +167,7 @@ export default class UI extends Module<UINodes> {
/**
* Bind events for the UI elements
*/
this.enableModuleBindings();
this.bindReadOnlySensitiveListeners();
}, {
timeout: 2000,
});
@ -169,7 +176,7 @@ export default class UI extends Module<UINodes> {
* Unbind all events
*
*/
this.disableModuleBindings();
this.unbindReadOnlySensitiveListeners();
}
}
@ -222,6 +229,8 @@ export default class UI extends Module<UINodes> {
*/
public destroy(): void {
this.nodes.holder.innerHTML = '';
this.unbindReadOnlyInsensitiveListeners();
}
/**
@ -291,6 +300,8 @@ export default class UI extends Module<UINodes> {
this.nodes.wrapper.appendChild(this.nodes.redactor);
this.nodes.holder.appendChild(this.nodes.wrapper);
this.bindReadOnlyInsensitiveListeners();
}
/**
@ -334,9 +345,29 @@ export default class UI extends Module<UINodes> {
}
/**
* Bind events on the Editor.js interface
* Adds listeners that should work both in read-only and read-write modes
*/
private enableModuleBindings(): void {
private bindReadOnlyInsensitiveListeners(): void {
this.listeners.on(document, 'selectionchange', this.selectionChangeDebounced);
this.listeners.on(window, 'resize', this.resizeDebouncer, {
passive: true,
});
}
/**
* Removes listeners that should work both in read-only and read-write modes
*/
private unbindReadOnlyInsensitiveListeners(): void {
this.listeners.off(document, 'selectionchange', this.selectionChangeDebounced);
this.listeners.off(window, 'resize', this.resizeDebouncer);
}
/**
* Adds listeners that should work only in read-only mode
*/
private bindReadOnlySensitiveListeners(): void {
this.readOnlyMutableListeners.on(this.nodes.redactor, 'click', (event: MouseEvent) => {
this.redactorClicked(event);
}, false);
@ -363,21 +394,6 @@ export default class UI extends Module<UINodes> {
this.documentClicked(event);
}, true);
/**
* Handle selection change to manipulate Inline Toolbar appearance
*/
const selectionChangeDebounced = _.debounce(() => {
this.selectionChanged();
}, selectionChangeDebounceTimeout);
this.readOnlyMutableListeners.on(document, 'selectionchange', selectionChangeDebounced, true);
this.readOnlyMutableListeners.on(window, 'resize', () => {
this.resizeDebouncer();
}, {
passive: true,
});
/**
* Start watching 'block-hovered' events that is used by Toolbar for moving
*/
@ -430,9 +446,9 @@ export default class UI extends Module<UINodes> {
}
/**
* Unbind events on the Editor.js interface
* Unbind events that should work only in read-only mode
*/
private disableModuleBindings(): void {
private unbindReadOnlySensitiveListeners(): void {
this.readOnlyMutableListeners.clearAll();
}
@ -855,9 +871,11 @@ export default class UI extends Module<UINodes> {
/**
* Event can be fired on clicks at non-block-content elements,
* for example, at the Inline Toolbar or some Block Tune element
* for example, at the Inline Toolbar or some Block Tune element.
* We also make sure that the closest block belongs to the current editor and not a parent
*/
const clickedOutsideBlockContent = focusedElement.closest(`.${Block.CSS.content}`) === null;
const closestBlock = focusedElement.closest(`.${Block.CSS.content}`);
const clickedOutsideBlockContent = closestBlock === null || (closestBlock.closest(`.${Selection.CSS.editorWrapper}`) !== this.nodes.wrapper);
if (clickedOutsideBlockContent) {
/**

View file

@ -317,7 +317,7 @@ export default class SelectionUtils {
*
* @returns {Selection}
*/
public static get(): Selection {
public static get(): Selection | null {
return window.getSelection();
}

View file

@ -86,6 +86,12 @@ export enum InternalInlineToolSettings {
* Inline Tool title for toolbar
*/
Title = 'title', // for Inline Tools. Block Tools can pass title along with icon through the 'toolbox' static prop.
/**
* Allows inline tool to be available in read-only mode
* Can be used, for example, by comments tool
*/
IsReadOnlySupported = 'isReadOnlySupported',
}
/**

View file

@ -34,4 +34,12 @@ export default class InlineToolAdapter extends BaseToolAdapter<ToolType.Inline,
config: this.settings,
}) as IInlineTool;
}
/**
* Allows inline tool to be available in read-only mode
* Can be used, for example, by comments tool
*/
public get isReadOnlySupported(): boolean {
return this.constructable[InternalInlineToolSettings.IsReadOnlySupported] ?? false;
}
}

View file

@ -308,7 +308,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
/**
* Maps tool data to popover item structure
*/
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockToolAdapter): PopoverItemParams => {
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockToolAdapter, displaySecondaryLabel = true): PopoverItemParams => {
return {
icon: toolboxItem.icon,
title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
@ -316,15 +316,15 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
onActivate: (): void => {
this.toolButtonActivated(tool.name, toolboxItem.data);
},
secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
secondaryLabel: (tool.shortcut && displaySecondaryLabel) ? _.beautifyShortcut(tool.shortcut) : '',
};
};
return this.toolsToBeDisplayed
.reduce<PopoverItemParams[]>((result, tool) => {
if (Array.isArray(tool.toolbox)) {
tool.toolbox.forEach(item => {
result.push(toPopoverItem(item, tool));
tool.toolbox.forEach((item, index) => {
result.push(toPopoverItem(item, tool, index === 0));
});
} else if (tool.toolbox !== undefined) {
result.push(toPopoverItem(tool.toolbox, tool));

View file

@ -1,4 +1,4 @@
import type { BlockAPI } from '../../../types';
import type { BlockAPI, ToolConfig } from '../../../types';
import type { ConversionConfig } from '../../../types/configs/conversion-config';
import type { SavedData } from '../../../types/data-formats';
import type { BlockToolData } from '../../../types/tools/block-tool-data';
@ -51,6 +51,15 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools
const savedData = await block.save() as SavedData;
const blockData = savedData.data;
/**
* Checking that the block's tool has an «export» rule
*/
const blockTool = allBlockTools.find((tool) => tool.name === block.name);
if (blockTool !== undefined && !isToolConvertable(blockTool, 'export')) {
return [];
}
return allBlockTools.reduce((result, tool) => {
/**
* Skip tools without «import» rule specified
@ -59,12 +68,19 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools
return result;
}
/**
* Skip tools that does not specify toolbox
*/
if (tool.toolbox === undefined) {
return result;
}
/** Filter out invalid toolbox entries */
const actualToolboxItems = tool.toolbox.filter((toolboxItem) => {
/**
* Skip items that don't pass 'toolbox' property or do not have an icon
*/
if (isEmpty(toolboxItem) || !toolboxItem.icon) {
if (isEmpty(toolboxItem) || toolboxItem.icon === undefined) {
return false;
}
@ -86,10 +102,10 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools
result.push({
...tool,
toolbox: actualToolboxItems,
});
} as BlockToolAdapter);
return result;
}, []);
}, [] as BlockToolAdapter[]);
}
@ -158,12 +174,13 @@ export function convertBlockDataToString(blockData: BlockToolData, conversionCon
*
* @param stringToImport - string to convert
* @param conversionConfig - tool's conversion config
* @param targetToolConfig - target tool config, used in conversionConfig.import method
*/
export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig): BlockToolData {
export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig, targetToolConfig?: ToolConfig): BlockToolData {
const importProp = conversionConfig?.import;
if (isFunction(importProp)) {
return importProp(stringToImport);
return importProp(stringToImport, targetToolConfig);
} else if (isString(importProp)) {
return {
[importProp]: stringToImport,

View file

@ -47,8 +47,15 @@ export async function getKeyboardKeyForCode(code: string, fallback: string): Pro
return fallback;
}
const map = await keyboard.getLayoutMap();
const key = map.get(code);
try {
const map = await keyboard.getLayoutMap();
return key || fallback;
const key = map.get(code);
return key || fallback;
} catch (e) {
console.error(e);
return fallback;
}
}

View file

@ -10,7 +10,7 @@ import type {
*/
export class SimpleHeader implements BaseTool {
private _data: BlockToolData;
private element: HTMLHeadingElement;
private element: HTMLHeadingElement | null = null;
/**
*
@ -39,10 +39,7 @@ export class SimpleHeader implements BaseTool {
* @param data - saved data to merger with current block
*/
public merge(data: BlockToolData): void {
this.data = {
text: this.data.text + data.text,
level: this.data.level,
};
this.element?.insertAdjacentHTML('beforeend', data.text);
}
/**
@ -66,25 +63,4 @@ export class SimpleHeader implements BaseTool {
import: 'text', // fill 'text' property from other block's export string
};
}
/**
* Data getter
*/
private get data(): BlockToolData {
this._data.text = this.element.innerHTML;
this._data.level = 1;
return this._data;
}
/**
* Data setter
*/
private set data(data: BlockToolData) {
this._data = data;
if (data.text !== undefined) {
this.element.innerHTML = this._data.text || '';
}
}
}

View file

@ -3,7 +3,7 @@ import type { BlockTool, BlockToolConstructorOptions } from '../../../../types';
/**
* Simple structure for Tool data
*/
interface MockToolData {
export interface MockToolData {
text: string;
}

View file

@ -0,0 +1,23 @@
import type { ConversionConfig } from '@/types/configs/conversion-config';
import ToolMock from './ToolMock';
/**
* This tool has a conversionConfig, but it doesn't have export property.
*
* That means that tool can be created from string, but can't be converted to string.
*/
export class ToolWithoutConversionExport extends ToolMock {
/**
* Rules specified how our Tool can be converted to/from other Tool.
*/
public static get conversionConfig(): ConversionConfig {
return {
import: 'text', // this tool can be created from string
/**
* Here is no "export" property, so this tool can't be converted to string
*/
// export: (data) => data.text,
};
}
}

View file

@ -0,0 +1,31 @@
import type { BlockTool, BlockToolConstructorOptions } from '../../../../types';
import { createEditorWithTextBlocks } from './createEditorWithTextBlocks';
export const NESTED_EDITOR_ID = 'nested-editor';
/**
* Creates nested Editor instance with paragraph block
*/
export default class NestedEditor implements BlockTool {
private data: { text: string };
constructor(value: BlockToolConstructorOptions) {
this.data = value.data;
}
public render(): HTMLDivElement {
const editorEl = Object.assign(document.createElement('div'), {
id: NESTED_EDITOR_ID,
});
editorEl.setAttribute('data-cy', NESTED_EDITOR_ID);
createEditorWithTextBlocks([ this.data.text ], { holder: NESTED_EDITOR_ID });
return editorEl;
}
public save(): string {
return this.data.text;
}
}

View file

@ -1,6 +1,6 @@
import type EditorJS from '../../../../types/index';
import type { ConversionConfig, ToolboxConfig } from '../../../../types';
import ToolMock from '../../fixtures/tools/ToolMock';
import type { ConversionConfig, ToolboxConfig, ToolConfig } from '../../../../types';
import ToolMock, { type MockToolData } from '../../fixtures/tools/ToolMock';
import { nanoid } from 'nanoid';
/**
@ -444,5 +444,84 @@ describe('api.blocks', () => {
});
});
});
it('should pass tool config to the conversionConfig.import method of the tool', function () {
const existingBlock = {
id: 'test-id-123',
type: 'paragraph',
data: {
text: 'Some text',
},
};
const conversionTargetToolConfig = {
defaultStyle: 'defaultStyle',
};
/**
* Mock of Tool with conversionConfig
*/
class ToolWithConversionConfig extends ToolMock {
/**
* Specify conversion config of the tool
*/
public static get conversionConfig(): {
/**
* Method that is responsible for conversion from data to string
*/
export: (data: string) => string;
/**
* Method that is responsible for conversion from string to data
* Should return stringified config to see, if Editor actually passed tool config to it
*/
import: (content: string, config: ToolConfig) => MockToolData;
} {
return {
export: (data) => data,
/**
* Passed config should be returned
*/
import: (_content, config) => {
return { text: JSON.stringify(config) };
},
};
}
}
cy.createEditor({
tools: {
conversionTargetTool: {
class: ToolWithConversionConfig,
config: conversionTargetToolConfig,
},
},
data: {
blocks: [
existingBlock,
],
},
}).then(async (editor) => {
const { convert } = editor.blocks;
await convert(existingBlock.id, 'conversionTargetTool');
// wait for block to be converted
cy.wait(100).then(async () => {
/**
* Check that block was converted
*/
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('conversionTargetTool');
/**
* Check that tool converted returned config as a result of import
*/
expect(blocks[0].data.text).to.eq(JSON.stringify(conversionTargetToolConfig));
});
});
});
});
});

View file

@ -1,4 +1,6 @@
import Header from '@editorjs/header';
import NestedEditor, { NESTED_EDITOR_ID } from '../../support/utils/nestedEditorInstance';
import type { MenuConfig } from '@/types/tools';
describe('Inline Toolbar', () => {
it('should appear aligned with left coord of selection rect', () => {
@ -76,6 +78,59 @@ describe('Inline Toolbar', () => {
});
});
it('should be displayed in read-only mode if at least one inline tool of block supports it', () => {
cy.createEditor({
tools: {
header: {
class: Header,
inlineToolbar: ['bold', 'testTool'],
},
testTool: {
class: class {
public static isInline = true;
public static isReadOnlySupported = true;
// eslint-disable-next-line jsdoc/require-jsdoc
public render(): MenuConfig {
return {
title: 'Test Tool',
name: 'test-tool',
// eslint-disable-next-line @typescript-eslint/no-empty-function
onActivate: () => {},
};
}
},
},
},
readOnly: true,
data: {
blocks: [
{
type: 'header',
data: {
text: 'First block text',
},
},
],
},
});
/** Open Inline Toolbar */
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.selectText('block');
cy.get('[data-cy=editorjs]')
.get('[data-cy=inline-toolbar]')
.get('.ce-popover--opened')
.as('toolbar')
.should('exist');
cy.get('@toolbar')
.get('.ce-popover-item')
.should('have.length', 1)
.should('have.attr', 'data-item-name', 'test-tool');
});
it('should not submit form nesting editor when inline tool clicked', () => {
cy.createEditor({
data: {
@ -164,4 +219,56 @@ describe('Inline Toolbar', () => {
});
});
});
describe('Nested Editor instance inline toolbar', () => {
it('should not close inline toolbar of the nested Editor instance when clicking within that toolbar', () => {
cy.createEditor({
tools: {
nestedEditor: {
class: NestedEditor,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
},
{
type: 'nestedEditor',
data: {
text: 'Nunc pellentesque, tortor nec luctus venenatis',
},
},
],
},
});
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('.ce-paragraph')
.selectText('tortor nec luctus');
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('[data-item-name=link]')
.click();
// `wait()` function below is required. without it the test will always pass
// because cypress types the text in the field without delay, while we need some delay (just like user)
// to test the actual case that nested editor inline toolbar is still visible and not closed
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('.ce-inline-tool-input')
.click()
.wait(100)
.type('https://editorjs.io');
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('.ce-popover__container')
.then(($toolbar) => {
expect($toolbar).to.be.visible;
});
});
});
});

View file

@ -21,6 +21,7 @@ describe('InlineTool', () => {
public static prepare;
public static shortcut = 'CTRL+N';
public static isReadOnlySupported = true;
public api: object;
public config: ToolSettings;
@ -192,4 +193,21 @@ describe('InlineTool', () => {
expect(instance.config).to.be.deep.eq(options.config.config);
});
});
context('.isReadOnlySupported', () => {
it('should return Tool provided value', () => {
const tool = new InlineToolAdapter(options as any);
expect(tool.isReadOnlySupported).to.be.eq(options.constructable.isReadOnlySupported);
});
it('should return false if Tool provided value is not exist', () => {
const tool = new InlineToolAdapter({
...options,
constructable: {},
} as any);
expect(tool.isReadOnlySupported).to.be.false;
});
});
});

View file

@ -1,8 +1,8 @@
import { selectionChangeDebounceTimeout } from '../../../../src/components/constants';
import Header from '@editorjs/header';
import type { ToolboxConfig } from '../../../../types';
import type { ConversionConfig, ToolboxConfig } from '../../../../types';
import type { MenuConfig } from '../../../../types/tools';
import { ToolWithoutConversionExport } from '../../fixtures/tools/ToolWithoutConversionExport';
describe('BlockTunes', function () {
describe('Search', () => {
@ -185,6 +185,39 @@ describe('BlockTunes', function () {
.should('not.exist');
});
it('should not display the ConvertTo control if block has no conversionConfig.export specified', () => {
cy.createEditor({
tools: {
testTool: ToolWithoutConversionExport,
},
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'Some text',
},
},
],
},
}).as('editorInstance');
cy.get('@editorInstance')
.get('[data-cy=editorjs]')
.find('.ce-block')
.click();
cy.get('@editorInstance')
.get('[data-cy=editorjs]')
.find('.ce-toolbar__settings-btn')
.click();
cy.get('@editorInstance')
.get('[data-cy=editorjs]')
.find('.ce-popover-item[data-item-name=convert-to]')
.should('not.exist');
});
it('should not display tool with the same data in "Convert to" menu', () => {
/**
* Tool with several toolbox entries configured
@ -193,9 +226,10 @@ describe('BlockTunes', function () {
/**
* Tool is convertable
*/
public static get conversionConfig(): { import: string } {
public static get conversionConfig(): ConversionConfig {
return {
import: 'text',
export: 'text',
};
}

View file

@ -114,5 +114,105 @@ describe('Toolbox', function () {
expect(blocks[1].type).to.eq('nonConvertableTool');
});
});
it('should display shortcut only for the first toolbox item if tool exports toolbox with several items', function () {
/**
* Mock of Tool with conversionConfig
*/
class ToolWithSeveralToolboxItems extends ToolMock {
/**
* Specify toolbox with several items related to one tool
*/
public static get toolbox(): ToolboxConfig {
return [
{
icon: '',
title: 'first tool',
},
{
icon: '',
title: 'second tool',
},
];
}
}
cy.createEditor({
tools: {
severalToolboxItemsTool: {
class: ToolWithSeveralToolboxItems,
shortcut: 'CMD+SHIFT+L',
},
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('Some text')
.type('/'); // call a shortcut for toolbox
/**
* Secondary title (shortcut) should exist for first toolbox item of the tool
*/
/* eslint-disable-next-line cypress/require-data-selectors */
cy.get('.ce-popover')
.find('.ce-popover-item[data-item-name="severalToolboxItemsTool"]')
.first()
.find('.ce-popover-item__secondary-title')
.should('exist');
/**
* Secondary title (shortcut) should not exist for second toolbox item of the same tool
*/
/* eslint-disable-next-line cypress/require-data-selectors */
cy.get('.ce-popover')
.find('.ce-popover-item[data-item-name="severalToolboxItemsTool"]')
.eq(1)
.find('.ce-popover-item__secondary-title')
.should('not.exist');
});
it('should display shortcut for the item if tool exports toolbox as an one item object', function () {
/**
* Mock of Tool with conversionConfig
*/
class ToolWithOneToolboxItems extends ToolMock {
/**
* Specify toolbox with several items related to one tool
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'tool',
};
}
}
cy.createEditor({
tools: {
oneToolboxItemTool: {
class: ToolWithOneToolboxItems,
shortcut: 'CMD+SHIFT+L',
},
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('Some text')
.type('/'); // call a shortcut for toolbox
/**
* Secondary title (shortcut) should exist for toolbox item of the tool
*/
/* eslint-disable-next-line cypress/require-data-selectors */
cy.get('.ce-popover')
.find('.ce-popover-item[data-item-name="oneToolboxItemTool"]')
.first()
.find('.ce-popover-item__secondary-title')
.should('exist');
});
});
});

View file

@ -87,9 +87,9 @@ describe('Flipper', () => {
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE });
/**
* Check whether we focus the Move Up Tune or not
* Check whether we focus the Delete Tune or not
*/
cy.get('[data-item-name="move-up"]')
cy.get('[data-item-name="delete"]')
.should('have.class', 'ce-popover-item--focused');
cy.get('[data-cy=editorjs]')

View file

@ -1,4 +1,4 @@
import type { BlockToolData } from '../tools';
import type { BlockToolData, ToolConfig } from '../tools';
/**
* Config allows Tool to specify how it can be converted into/from another Tool
@ -12,7 +12,7 @@ export interface ConversionConfig {
* 1. String the key of Tool data object to fill it with imported string on render.
* 2. Function method that accepts importing string and composes Tool data to render.
*/
import?: ((data: string) => string) | string;
import?: ((data: string, config: ToolConfig) => BlockToolData) | string;
/**
* How to export this Tool to make other Block.

View file

@ -1,6 +1,6 @@
import { ConversionConfig, PasteConfig, SanitizerConfig } from '../configs';
import { BlockToolData } from './block-tool-data';
import { BaseTool, BaseToolConstructable } from './tool';
import { BaseTool, BaseToolConstructable, BaseToolConstructorOptions } from './tool';
import { ToolConfig } from './tool-config';
import { API, BlockAPI, ToolboxConfig } from '../index';
import { PasteEvent } from './paste-events';
@ -83,10 +83,8 @@ export interface BlockTool extends BaseTool {
/**
* Describe constructor parameters
*/
export interface BlockToolConstructorOptions<D extends object = any, C extends object = any> {
api: API;
export interface BlockToolConstructorOptions<D extends object = any, C extends object = any> extends BaseToolConstructorOptions<C> {
data: BlockToolData<D>;
config: ToolConfig<C>;
block: BlockAPI;
readOnly: boolean;
}

View file

@ -57,4 +57,10 @@ export interface InlineToolConstructable extends BaseToolConstructable {
* @param {InlineToolConstructorOptions} config - constructor parameters
*/
new(config: InlineToolConstructorOptions): BaseTool;
/**
* Allows inline tool to be available in read-only mode
* Can be used, for example, by comments tool
*/
isReadOnlySupported?: boolean;
}

View file

@ -22,7 +22,7 @@ export interface ToolboxConfigEntry {
icon?: string;
/**
* May contain overrides for tool default config
* May contain overrides for tool default data
*/
data?: BlockToolData
}

23
types/tools/tool.d.ts vendored
View file

@ -9,15 +9,27 @@ import {MenuConfig} from './menu-config';
export interface BaseTool<RenderReturnType = HTMLElement> {
/**
* Tool`s render method
*
* For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig}
*
* For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig}
* @see https://editorjs.io/menu-config
*
*
* For Block Tools returns tool`s wrapper html element
*/
render(): RenderReturnType | Promise<RenderReturnType>;
}
export interface BaseToolConstructorOptions<C extends object = any> {
/**
* Editor.js API
*/
api: API;
/**
* Tool configuration
*/
config?: ToolConfig<C>;
}
export interface BaseToolConstructable {
/**
* Define Tool type as Inline
@ -35,11 +47,6 @@ export interface BaseToolConstructable {
*/
title?: string;
/**
* Describe constructor parameters
*/
new (config: {api: API, config?: ToolConfig}): BaseTool;
/**
* Tool`s prepare method. Can be async
* @param data

View file

@ -504,7 +504,7 @@
js-yaml "4.1.0"
nyc "15.1.0"
"@cypress/request@^3.0.0":
"@cypress/request@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.1.tgz#72d7d5425236a2413bd3d8bb66d02d9dc3168960"
integrity sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==
@ -550,6 +550,13 @@
debug "^3.1.0"
lodash.once "^4.1.1"
"@editorjs/caret@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@editorjs/caret/-/caret-1.0.1.tgz#0d33ca67a2d29d09fdea10d3d30b660f0abc7cfd"
integrity sha512-yMewrc/dndBbgmluFory0GbVWXnD9rhcE/xgwM0ecHWQodyfY3ZIJLvSQhf+BbgncitMlUG/FYqjJCL2Axi4+g==
dependencies:
"@editorjs/dom" "^1.0.0"
"@editorjs/code@^2.7.0":
version "2.8.0"
resolved "https://registry.yarnpkg.com/@editorjs/code/-/code-2.8.0.tgz#d31fdd947b9c763daae2cd2eabdf8dc37c0c6f5a"
@ -564,19 +571,31 @@
dependencies:
"@codexteam/icons" "^0.0.5"
"@editorjs/dom@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@editorjs/dom/-/dom-1.0.0.tgz#ddf7f17651a091570766c5fa44c89ecf8a183c82"
integrity sha512-P5qZaQaG8NQXm2XuEDlcfDm8S1Kvdegwf0E/ld2RnwZquY5l27hufaW57w0SikT75mscr+dARQ68Gx/xEQEUKw==
dependencies:
"@editorjs/helpers" "^1.0.0"
"@editorjs/editorjs@^2.29.1":
version "2.30.2"
resolved "https://registry.yarnpkg.com/@editorjs/editorjs/-/editorjs-2.30.2.tgz#b045af18a9ebe0c02cb32be41b2a98e23ee08e59"
integrity sha512-JjtUDs2/aHTEjNZzEf/2cugpIli1+aNeU8mloOd5USbVxv2vC02HTMpv7Vc1UyB7dIuc45JaYSJwgnBZp9duhA==
"@editorjs/header@^2.8.7":
version "2.8.7"
resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.8.7.tgz#6aa34e01638d18fbbc6d3bd75f1844869eca9193"
integrity sha512-rfxzYFR/Jhaocj3Xxx8XjEjyzfPbBIVkcPZ9Uy3rEz1n3ewhV0V4zwuxCjVfFhLUVgQQExq43BxJnTNlLOzqDQ==
"@editorjs/header@^2.8.8":
version "2.8.8"
resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.8.8.tgz#43cff7949c44866da7716fdb562d68116d0a806a"
integrity sha512-bsMSs34u2hoi0UBuRoc5EGWXIFzJiwYgkFUYQGVm63y5FU+s8zPBmVx5Ip2sw1xgs0fqfDROqmteMvvmbCy62w==
dependencies:
"@codexteam/icons" "^0.0.5"
"@editorjs/editorjs" "^2.29.1"
"@editorjs/helpers@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@editorjs/helpers/-/helpers-1.0.0.tgz#4b0e0868e51e2772a73212f4aac5aff553725894"
integrity sha512-ih4yCm+x+7X9XCn1zxfNous2LQX8ZYMyTHMLdgbyjBf0Opf8GdLxVjdzSjkA+0mUp1tUe3JgWW3FTisYcSnbQA==
"@editorjs/paragraph@^2.11.6":
version "2.11.6"
resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.11.6.tgz#011444187a74dc603201dce37d2fc6d054022407"
@ -1718,12 +1737,12 @@ cypress-vite@^1.5.0:
chokidar "^3.5.3"
debug "^4.3.4"
cypress@^13.7.1:
version "13.7.1"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.7.1.tgz#d1208eb04efd46ef52a30480a5da71a03373261a"
integrity sha512-4u/rpFNxOFCoFX/Z5h+uwlkBO4mWzAjveURi3vqdSu56HPvVdyGTxGw4XKGWt399Y1JwIn9E1L9uMXQpc0o55w==
cypress@^13.13.3:
version "13.13.3"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.13.3.tgz#21ee054bb4e00b3858f2e33b4f8f4e69128470a9"
integrity sha512-hUxPrdbJXhUOTzuML+y9Av7CKoYznbD83pt8g3klgpioEha0emfx4WNIuVRx0C76r0xV2MIwAW9WYiXfVJYFQw==
dependencies:
"@cypress/request" "^3.0.0"
"@cypress/request" "^3.0.1"
"@cypress/xvfb" "^1.2.4"
"@types/sinonjs__fake-timers" "8.1.1"
"@types/sizzle" "^2.3.2"
@ -1762,7 +1781,7 @@ cypress@^13.7.1:
request-progress "^3.0.0"
semver "^7.5.3"
supports-color "^8.1.1"
tmp "~0.2.1"
tmp "~0.2.3"
untildify "^4.0.0"
yauzl "^2.10.0"
@ -4999,7 +5018,7 @@ through@^2.3.8:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
tmp@~0.2.1:
tmp@~0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==