Merge branch 'next' of github.com:codex-team/editor.js into feature/clean-inline-fragments

This commit is contained in:
George Berezhnoy 2022-11-16 19:15:50 +00:00
commit 6ac47d1d8a
45 changed files with 2206 additions and 626 deletions

View file

@ -28,8 +28,8 @@
- Unified Toolbox
- [x] Block Tunes moved left [#1815](https://github.com/codex-team/editor.js/pull/1815)
- [x] Toolbox become vertical [#2014](https://github.com/codex-team/editor.js/pull/2014)
- [ ] Ability to display several Toolbox buttons by the single Tool `In progress`
- [ ] Conversion Toolbar uses Unified Toolbox
- [x] Ability to display several Toolbox buttons by the single Tool [#2050](https://github.com/codex-team/editor.js/pull/2050)
- [ ] Conversion Toolbar uses Unified Toolbox `In progress`
- [ ] Block Tunes become vertical
- [ ] Conversion Toolbar added to the Block Tunes
- Ecosystem improvements
@ -85,6 +85,8 @@ You can join a [Gitter-channel](https://gitter.im/codex-team/editor.js) or [Tele
See the whole [Changelog](/docs/CHANGELOG.md)
If you want to follow Editor.js updates, [subscribe to our Newsletter](http://digest.editorjs.io/).
## How to use Editor.js
### Basics

View file

@ -1,5 +1,29 @@
# Changelog
### 2.26.0
- `New`*UI* — Block Tunes became vertical just like the Toolbox 🤩
- `New`*Block Tunes API* — Now `render()` method of a Block Tune can return config with just icon, label and callback instead of custom HTML. This impovement is a key to the new straightforward way of configuring tune's appearance in Block Tunes menu.
- `New`*Tools API* — As well as `render()` in `Tunes API`, Tool's `renderSettings()` now also supports new configuration format.
- `Deprecated`*Styles API* — CSS classes `.cdx-settings-button` and `.cdx-settings-button--active` are not recommended to use. Consider configuring your block settings with new JSON API instead.
- `Fix` — Wrong element not highlighted anymore when popover opened.
- `Fix` — When Tunes Menu open keydown events can not be handled inside plugins.
### 2.25.0
- `New`*Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool! <br>
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)
### 2.24.3
- `Fix` — Issue with toolbox preventing text selection fixed
### 2.24.2
- `Fix` — Scrolling issue when opening toolbox on mobile fixed

View file

@ -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` <br /> `icon` - HTML string with icon for Toolbox <br /> `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` <br /> `icon` - HTML string with icon for the Toolbox <br /> `title` - title to be displayed at the Toolbox. <br /><br />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 `<code>` 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) |

View file

@ -194,9 +194,11 @@
"toolbar": {
"toolbox": {
"Add": "Добавить",
"Filter": "Поиск",
"Nothing found": "Ничего не найдено"
}
},
"popover": {
"Filter": "Поиск",
"Nothing found": "Ничего не найдено"
}
},

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.24.2",
"version": "2.26.0-rc.0",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editor.js",
"types": "./types/index.d.ts",

View file

@ -4,7 +4,7 @@
*
* @copyright <CodeX Team> 2018
*/
import { API, BlockTune } from '../../../types';
import { API, BlockTune, PopoverItem } from '../../../types';
import $ from '../dom';
/**
@ -23,32 +23,6 @@ export default class DeleteTune implements BlockTune {
*/
private readonly api: API;
/**
* Styles
*/
private CSS = {
button: 'ce-settings__button',
buttonDelete: 'ce-settings__button--delete',
buttonConfirm: 'ce-settings__button--confirm',
};
/**
* Delete confirmation
*/
private needConfirmation: boolean;
/**
* set false confirmation state
*/
private readonly resetConfirmation: () => void;
/**
* Tune nodes
*/
private nodes: {button: HTMLElement} = {
button: null,
};
/**
* DeleteTune constructor
*
@ -56,30 +30,21 @@ export default class DeleteTune implements BlockTune {
*/
constructor({ api }) {
this.api = api;
this.resetConfirmation = (): void => {
this.setConfirmation(false);
};
}
/**
* Create "Delete" button and add click event listener
*
* @returns {HTMLElement}
* Tune's appearance in block settings menu
*/
public render(): HTMLElement {
this.nodes.button = $.make('div', [this.CSS.button, this.CSS.buttonDelete], {});
this.nodes.button.appendChild($.svg('cross', 12, 12));
this.api.listeners.on(this.nodes.button, 'click', (event: MouseEvent) => this.handleClick(event), false);
/**
* Enable tooltip module
*/
this.api.tooltip.onHover(this.nodes.button, this.api.i18n.t('Delete'), {
hidingDelay: 300,
});
return this.nodes.button;
public render(): PopoverItem {
return {
icon: $.svg('cross', 14, 14).outerHTML,
label: this.api.i18n.t('Delete'),
name: 'delete',
confirmation: {
label: this.api.i18n.t('Click to delete'),
onActivate: (item, e): void => this.handleClick(e),
},
};
}
/**
@ -88,43 +53,6 @@ export default class DeleteTune implements BlockTune {
* @param {MouseEvent} event - click event
*/
public handleClick(event: MouseEvent): void {
/**
* if block is not waiting the confirmation, subscribe on block-settings-closing event to reset
* otherwise delete block
*/
if (!this.needConfirmation) {
this.setConfirmation(true);
/**
* Subscribe on event.
* When toolbar block settings is closed but block deletion is not confirmed,
* then reset confirmation state
*/
this.api.events.on('block-settings-closed', this.resetConfirmation);
} else {
/**
* Unsubscribe from block-settings closing event
*/
this.api.events.off('block-settings-closed', this.resetConfirmation);
this.api.blocks.delete();
this.api.toolbar.close();
this.api.tooltip.hide();
/**
* Prevent firing ui~documentClicked that can drop currentBlock pointer
*/
event.stopPropagation();
}
}
/**
* change tune state
*
* @param {boolean} state - delete confirmation state
*/
private setConfirmation(state: boolean): void {
this.needConfirmation = state;
this.nodes.button.classList.add(this.CSS.buttonConfirm);
this.api.blocks.delete();
}
}

View file

@ -6,7 +6,8 @@
*/
import $ from '../dom';
import { API, BlockTune } from '../../../types';
import { API, BlockTune, PopoverItem } from '../../../types';
import Popover from '../utils/popover';
/**
*
@ -26,12 +27,8 @@ export default class MoveDownTune implements BlockTune {
/**
* Styles
*
* @type {{wrapper: string}}
*/
private CSS = {
button: 'ce-settings__button',
wrapper: 'ce-tune-move-down',
animation: 'wobble',
};
@ -45,43 +42,32 @@ export default class MoveDownTune implements BlockTune {
}
/**
* Return 'move down' button
*
* @returns {HTMLElement}
* Tune's appearance in block settings menu
*/
public render(): HTMLElement {
const moveDownButton = $.make('div', [this.CSS.button, this.CSS.wrapper], {});
moveDownButton.appendChild($.svg('arrow-down', 14, 14));
this.api.listeners.on(
moveDownButton,
'click',
(event) => this.handleClick(event as MouseEvent, moveDownButton),
false
);
/**
* Enable tooltip module on button
*/
this.api.tooltip.onHover(moveDownButton, this.api.i18n.t('Move down'), {
hidingDelay: 300,
});
return moveDownButton;
public render(): PopoverItem {
return {
icon: $.svg('arrow-down', 14, 14).outerHTML,
label: this.api.i18n.t('Move down'),
onActivate: (item, event): void => this.handleClick(event),
name: 'move-down',
};
}
/**
* Handle clicks on 'move down' button
*
* @param {MouseEvent} event - click event
* @param {HTMLElement} button - clicked button
* @param event - click event
*/
public handleClick(event: MouseEvent, button: HTMLElement): void {
public handleClick(event: MouseEvent): void {
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
const nextBlock = this.api.blocks.getBlockByIndex(currentBlockIndex + 1);
// If Block is last do nothing
if (!nextBlock) {
const button = (event.target as HTMLElement)
.closest('.' + Popover.CSS.item)
.querySelector('.' + Popover.CSS.itemIcon);
button.classList.add(this.CSS.animation);
window.setTimeout(() => {
@ -110,8 +96,5 @@ export default class MoveDownTune implements BlockTune {
this.api.blocks.move(currentBlockIndex + 1);
this.api.toolbar.toggleBlockSettings(true);
/** Hide the Tooltip */
this.api.tooltip.hide();
}
}

View file

@ -5,7 +5,8 @@
* @copyright <CodeX Team> 2018
*/
import $ from '../dom';
import { API, BlockTune } from '../../../types';
import { API, BlockTune, BlockAPI, PopoverItem } from '../../../types';
import Popover from '../../components/utils/popover';
/**
*
@ -25,12 +26,8 @@ export default class MoveUpTune implements BlockTune {
/**
* Styles
*
* @type {{wrapper: string}}
*/
private CSS = {
button: 'ce-settings__button',
wrapper: 'ce-tune-move-up',
animation: 'wobble',
};
@ -44,43 +41,32 @@ export default class MoveUpTune implements BlockTune {
}
/**
* Create "MoveUp" button and add click event listener
*
* @returns {HTMLElement}
* Tune's appearance in block settings menu
*/
public render(): HTMLElement {
const moveUpButton = $.make('div', [this.CSS.button, this.CSS.wrapper], {});
moveUpButton.appendChild($.svg('arrow-up', 14, 14));
this.api.listeners.on(
moveUpButton,
'click',
(event) => this.handleClick(event as MouseEvent, moveUpButton),
false
);
/**
* Enable tooltip module on button
*/
this.api.tooltip.onHover(moveUpButton, this.api.i18n.t('Move up'), {
hidingDelay: 300,
});
return moveUpButton;
public render(): PopoverItem {
return {
icon: $.svg('arrow-up', 14, 14).outerHTML,
label: this.api.i18n.t('Move up'),
onActivate: (item, e): void => this.handleClick(e),
name: 'move-up',
};
}
/**
* Move current block up
*
* @param {MouseEvent} event - click event
* @param {HTMLElement} button - clicked button
*/
public handleClick(event: MouseEvent, button: HTMLElement): void {
public handleClick(event: MouseEvent): void {
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
const previousBlock = this.api.blocks.getBlockByIndex(currentBlockIndex - 1);
if (currentBlockIndex === 0 || !currentBlock || !previousBlock) {
const button = (event.target as HTMLElement)
.closest('.' + Popover.CSS.item)
.querySelector('.' + Popover.CSS.itemIcon);
button.classList.add(this.CSS.animation);
window.setTimeout(() => {
@ -118,8 +104,5 @@ export default class MoveUpTune implements BlockTune {
this.api.blocks.move(currentBlockIndex - 1);
this.api.toolbar.toggleBlockSettings(true);
/** Hide the Tooltip */
this.api.tooltip.hide();
}
}

View file

@ -5,7 +5,9 @@ import {
BlockTune as IBlockTune,
InlineTool as IInlineTool,
SanitizerConfig,
ToolConfig
ToolConfig,
ToolboxConfigEntry,
PopoverItem
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@ -649,22 +651,33 @@ export default class Block extends EventsDispatcher<BlockEvents> {
}
/**
* Enumerates initialized tunes and returns fragment that can be appended to the toolbars area
*
* @returns {DocumentFragment[]}
* Returns data to render in tunes menu.
* Splits block tunes settings into 2 groups: popover items and custom html.
*/
public renderTunes(): [DocumentFragment, DocumentFragment] {
const tunesElement = document.createDocumentFragment();
const defaultTunesElement = document.createDocumentFragment();
public getTunes(): [PopoverItem[], HTMLElement] {
const customHtmlTunesContainer = document.createElement('div');
const tunesItems: PopoverItem[] = [];
this.tunesInstances.forEach((tune) => {
$.append(tunesElement, tune.render());
});
this.defaultTunesInstances.forEach((tune) => {
$.append(defaultTunesElement, tune.render());
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.defaultTunesInstances.values(),
...this.tunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
[tunesDefinedInTool, commonTunes].flat().forEach(rendered => {
if ($.isElement(rendered)) {
customHtmlTunesContainer.appendChild(rendered);
} else if (Array.isArray(rendered)) {
tunesItems.push(...rendered);
} else {
tunesItems.push(rendered);
}
});
return [tunesElement, defaultTunesElement];
return [tunesItems, customHtmlTunesContainer];
}
/**
@ -734,12 +747,45 @@ export default class Block extends EventsDispatcher<BlockEvents> {
}
/**
* Call Tool instance renderSettings method
* 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 renderSettings(): HTMLElement | undefined {
if (_.isFunction(this.toolInstance.renderSettings)) {
return this.toolInstance.renderSettings();
public async getActiveToolboxEntry(): Promise<ToolboxConfigEntry | undefined> {
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);
});
});
}
/**

View file

@ -60,6 +60,19 @@ export default class DomIterator {
return this.items[this.cursor];
}
/**
* Sets cursor to specified position
*
* @param cursorPosition - new cursor position
*/
public setCursor(cursorPosition: number): void {
if (cursorPosition < this.items.length && cursorPosition >= -1) {
this.dropCursor();
this.cursor = cursorPosition;
this.items[this.cursor].classList.add(this.focusedCssClass);
}
}
/**
* Sets items. Can be used when iterable items changed dynamically
*

View file

@ -40,6 +40,13 @@ export interface FlipperOptions {
* Flipper is a component that iterates passed items array by TAB or Arrows and clicks it by ENTER
*/
export default class Flipper {
/**
* True if flipper is currently activated
*/
public get isActivated(): boolean {
return this.activated;
}
/**
* Instance of flipper iterator
*
@ -64,6 +71,11 @@ export default class Flipper {
*/
private readonly activateCallback: (item: HTMLElement) => void;
/**
* Contains list of callbacks to be executed on each flip
*/
private flipCallbacks: Array<() => void> = []
/**
* @param {FlipperOptions} options - different constructing settings
*/
@ -93,21 +105,30 @@ export default class Flipper {
/**
* Active tab/arrows handling by flipper
*
* @param {HTMLElement[]} items - Some modules (like, InlineToolbar, BlockSettings) might refresh buttons dynamically
* @param items - Some modules (like, InlineToolbar, BlockSettings) might refresh buttons dynamically
* @param cursorPosition - index of the item that should be focused once flipper is activated
*/
public activate(items?: HTMLElement[]): void {
public activate(items?: HTMLElement[], cursorPosition?: number): void {
this.activated = true;
if (items) {
this.iterator.setItems(items);
}
if (cursorPosition !== undefined) {
this.iterator.setCursor(cursorPosition);
}
/**
* Listening all keydowns on document and react on TAB/Enter press
* TAB will leaf iterator items
* ENTER will click the focused item
*
* Note: the event should be handled in capturing mode on following reasons:
* - prevents plugins inner keydown handlers from being called while keyboard navigation
* - otherwise this handler will be called at the moment it is attached which causes false flipper firing (see https://techread.me/js-addeventlistener-fires-for-past-events/)
*/
document.addEventListener('keydown', this.onKeyDown);
document.addEventListener('keydown', this.onKeyDown, true);
}
/**
@ -151,6 +172,24 @@ export default class Flipper {
return !!this.iterator.currentItem;
}
/**
* Registeres function that should be executed on each navigation action
*
* @param cb - function to execute
*/
public onFlip(cb: () => void): void {
this.flipCallbacks.push(cb);
}
/**
* Unregisteres function that is executed on each navigation action
*
* @param cb - function to stop executing
*/
public removeOnFlip(cb: () => void): void {
this.flipCallbacks = this.flipCallbacks.filter(fn => fn !== cb);
}
/**
* Drops flipper's iterator cursor
*
@ -258,5 +297,7 @@ export default class Flipper {
if (this.iterator.currentItem) {
this.iterator.currentItem.scrollIntoViewIfNeeded();
}
this.flipCallbacks.forEach(cb => cb());
}
}

View file

@ -13,10 +13,12 @@
},
"toolbar": {
"toolbox": {
"Add": "",
"Filter": "",
"Nothing found": ""
"Add": ""
}
},
"popover": {
"Filter": "",
"Nothing found": ""
}
},
"toolNames": {
@ -35,7 +37,8 @@
},
"blockTunes": {
"delete": {
"Delete": ""
"Delete": "",
"Click to delete": ""
},
"moveUp": {
"Move up": ""

View file

@ -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
@ -22,7 +23,7 @@ export default class BlocksAPI extends Module {
delete: (index?: number): void => this.delete(index),
swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex),
move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex),
getBlockByIndex: (index: number): BlockAPIInterface | void => this.getBlockByIndex(index),
getBlockByIndex: (index: number): BlockAPIInterface | undefined => this.getBlockByIndex(index),
getById: (id: string): BlockAPIInterface | null => this.getById(id),
getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(),
getBlockIndex: (id: string): number => this.getBlockIndex(id),
@ -31,6 +32,7 @@ export default class BlocksAPI extends Module {
insertNewBlock: (): void => this.insertNewBlock(),
insert: this.insert,
update: this.update,
composeBlockData: this.composeBlockData,
};
}
@ -75,7 +77,7 @@ export default class BlocksAPI extends Module {
*
* @param {number} index - index to get
*/
public getBlockByIndex(index: number): BlockAPIInterface | void {
public getBlockByIndex(index: number): BlockAPIInterface | undefined {
const block = this.Editor.BlockManager.getBlockByIndex(index);
if (block === undefined) {
@ -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<BlockToolData> => {
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

View file

@ -129,13 +129,14 @@ export default class BlockEvents extends Module {
const canOpenToolbox = currentBlock.tool.isDefault && isEmptyBlock;
const conversionToolbarOpened = !isEmptyBlock && ConversionToolbar.opened;
const inlineToolbarOpened = !isEmptyBlock && !SelectionUtils.isCollapsed && InlineToolbar.opened;
const canOpenBlockTunes = !conversionToolbarOpened && !inlineToolbarOpened;
/**
* For empty Blocks we show Plus button via Toolbox only for default Blocks
*/
if (canOpenToolbox) {
this.activateToolbox();
} else if (!conversionToolbarOpened && !inlineToolbarOpened) {
} else if (canOpenBlockTunes) {
this.activateBlockSettings();
}
}

View file

@ -101,8 +101,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({

View file

@ -1,30 +1,23 @@
import Module from '../../__module';
import $ from '../../dom';
import Flipper, { FlipperOptions } from '../../flipper';
import * as _ from '../../utils';
import SelectionUtils from '../../selection';
import Block from '../../block';
import Popover, { PopoverEvent } from '../../utils/popover';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Flipper from '../../flipper';
/**
* HTML Elements that used for BlockSettings
*/
interface BlockSettingsNodes {
wrapper: HTMLElement;
toolSettings: HTMLElement;
defaultSettings: HTMLElement;
}
/**
* Block Settings
*
* ____ Settings Panel ____
* | ...................... |
* | . Tool Settings . |
* | ...................... |
* | . Default Settings . |
* | ...................... |
* |________________________|
*
* @todo Make Block Settings no-module but a standalone class, like Toolbox
*/
export default class BlockSettings extends Module<BlockSettingsNodes> {
@ -42,82 +35,50 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Block Settings CSS
*
* @returns {{wrapper, wrapperOpened, toolSettings, defaultSettings, button}}
*/
public get CSS(): { [name: string]: string } {
return {
// Settings Panel
wrapper: 'ce-settings',
wrapperOpened: 'ce-settings--opened',
toolSettings: 'ce-settings__plugin-zone',
defaultSettings: 'ce-settings__default-zone',
button: 'ce-settings__button',
focusedButton: 'ce-settings__button--focused',
focusedButtonAnimated: 'ce-settings__button--focused-animated',
settings: 'ce-settings',
};
}
/**
* Is Block Settings opened or not
*
* @returns {boolean}
* Opened state
*/
public get opened(): boolean {
return this.nodes.wrapper.classList.contains(this.CSS.wrapperOpened);
public opened = false;
/**
* Getter for inner popover's flipper instance
*
* @todo remove once BlockSettings becomes standalone non-module class
*/
public get flipper(): Flipper {
return this.popover.flipper;
}
/**
* List of buttons
*/
private buttons: HTMLElement[] = [];
/**
* Instance of class that responses for leafing buttons by arrows/tab
*
* @type {Flipper|null}
*/
private flipper: Flipper = null;
/**
* Page selection utils
*/
private selection: SelectionUtils = new SelectionUtils();
/**
* Popover instance. There is a util for vertical lists.
*/
private popover: Popover;
/**
* Panel with block settings with 2 sections:
* - Tool's Settings
* - Default Settings [Move, Remove, etc]
*/
public make(): void {
this.nodes.wrapper = $.make('div', this.CSS.wrapper);
this.nodes.toolSettings = $.make('div', this.CSS.toolSettings);
this.nodes.defaultSettings = $.make('div', this.CSS.defaultSettings);
$.append(this.nodes.wrapper, [this.nodes.toolSettings, this.nodes.defaultSettings]);
/**
* Active leafing by arrows/tab
* Buttons will be filled on opening
*/
this.enableFlipper();
this.nodes.wrapper = $.make('div');
}
/**
* Destroys module
*/
public destroy(): void {
/**
* Sometimes (in read-only mode) there is no Flipper
*/
if (this.flipper) {
this.flipper.deactivate();
this.flipper = null;
}
this.removeAllNodes();
}
@ -127,7 +88,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* @param targetBlock - near which Block we should open BlockSettings
*/
public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void {
this.nodes.wrapper.classList.add(this.CSS.wrapperOpened);
this.opened = true;
/**
* If block settings contains any inputs, focus will be set there,
@ -144,24 +105,41 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Fill Tool's settings
*/
this.addToolSettings(targetBlock);
/**
* Add default settings that presents for all Blocks
*/
this.addTunes(targetBlock);
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
/** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened);
this.flipper.activate(this.blockTunesButtons);
this.popover = new Popover({
className: this.CSS.settings,
searchable: true,
filterLabel: I18n.ui(I18nInternalNS.ui.popover, 'Filter'),
nothingFoundLabel: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
items: tunesItems,
customContent: customHtmlTunesContainer,
customContentFlippableItems: this.getControls(customHtmlTunesContainer),
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
});
this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked);
this.popover.on(PopoverEvent.Close, () => this.close());
this.nodes.wrapper.append(this.popover.getElement());
this.popover.show();
}
/**
* Returns root block settings element
*/
public getElement(): HTMLElement {
return this.nodes.wrapper;
}
/**
* Close Block Settings pane
*/
public close(): void {
this.nodes.wrapper.classList.remove(this.CSS.wrapperOpened);
this.opened = false;
/**
* If selection is at editor on Block Settings closing,
@ -183,106 +161,36 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.Editor.BlockManager.currentBlock.selected = false;
}
/** Clear settings */
this.nodes.toolSettings.innerHTML = '';
this.nodes.defaultSettings.innerHTML = '';
/** Tell to subscribers that block settings is closed */
this.eventsDispatcher.emit(this.events.closed);
/** Clear cached buttons */
this.buttons = [];
/** Clear focus on active button */
this.flipper.deactivate();
if (this.popover) {
this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked);
this.popover.destroy();
this.popover.getElement().remove();
this.popover = null;
}
}
/**
* Returns Tools Settings and Default Settings
* Returns list of buttons and inputs inside specified container
*
* @returns {HTMLElement[]}
* @param container - container to query controls inside of
*/
public get blockTunesButtons(): HTMLElement[] {
private getControls(container: HTMLElement): HTMLElement[] {
const { StylesAPI } = this.Editor;
/**
* Return from cache
* if exists
*/
if (this.buttons.length !== 0) {
return this.buttons;
}
const toolSettings = this.nodes.toolSettings.querySelectorAll(
// Select buttons and inputs
/** Query buttons and inputs inside tunes html */
const controls = container.querySelectorAll<HTMLElement>(
`.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}`
);
const defaultSettings = this.nodes.defaultSettings.querySelectorAll(`.${this.CSS.button}`);
toolSettings.forEach((item) => {
this.buttons.push((item as HTMLElement));
});
defaultSettings.forEach((item) => {
this.buttons.push((item as HTMLElement));
});
return this.buttons;
return Array.from(controls);
}
/**
* Add Tool's settings
*
* @param targetBlock - Block to render settings
* Handles overlay click
*/
private addToolSettings(targetBlock): void {
const settingsElement = targetBlock.renderSettings();
if (settingsElement) {
$.append(this.nodes.toolSettings, settingsElement);
}
}
/**
* Add tunes: provided by user and default ones
*
* @param targetBlock - Block to render its Tunes set
*/
private addTunes(targetBlock): void {
const [toolTunes, defaultTunes] = targetBlock.renderTunes();
$.append(this.nodes.toolSettings, toolTunes);
$.append(this.nodes.defaultSettings, defaultTunes);
}
/**
* Active leafing by arrows/tab
* Buttons will be filled on opening
*/
private enableFlipper(): void {
this.flipper = new Flipper({
focusedItemClass: this.CSS.focusedButton,
/**
* @param {HTMLElement} focusedItem - activated Tune
*/
activateCallback: (focusedItem) => {
/**
* If focused item is editable element, close block settings
*/
if (focusedItem && $.canSetCaret(focusedItem)) {
this.close();
return;
}
/**
* Restoring focus on current Block after settings clicked.
* For example, when H3 changed to H2 DOM Elements replaced, so we need to focus a new one
*/
_.delay(() => {
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
}, 50)();
},
} as FlipperOptions);
private onOverlayClicked = (): void => {
this.close();
}
}

View file

@ -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<ConversionToolbarNodes> {
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<ConversionToolbarNodes> {
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<ConversionToolbarNodes> {
* 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<ConversionToolbarNodes> {
* 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<void> {
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
/**
* 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<ConversionToolbarNodes> {
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<ConversionToolbarNodes> {
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<void> {
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);
});
}

View file

@ -167,7 +167,6 @@ export default class Toolbar extends Module<ToolbarNodes> {
opened: this.toolboxInstance.opened,
close: (): void => {
this.toolboxInstance.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
},
open: (): void => {
/**
@ -389,7 +388,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Appending Toolbar components to itself
*/
$.append(this.nodes.actions, this.makeToolbox());
$.append(this.nodes.actions, this.Editor.BlockSettings.nodes.wrapper);
$.append(this.nodes.actions, this.Editor.BlockSettings.getElement());
/**
* Append toolbar to the Editor
@ -408,8 +407,8 @@ export default class Toolbar extends Module<ToolbarNodes> {
api: this.Editor.API.methods,
tools: this.Editor.Tools.blockTools,
i18nLabels: {
filter: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Filter'),
nothingFound: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Nothing found'),
filter: I18n.ui(I18nInternalNS.ui.popover, 'Filter'),
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
},
});

View file

@ -467,7 +467,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Changes Conversion Dropdown content for current block's Tool
*/
private setConversionTogglerContent(): void {
private async setConversionTogglerContent(): Promise<void> {
const { BlockManager } = this.Editor;
const { currentBlock } = BlockManager;
const toolName = currentBlock.name;
@ -484,7 +484,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Get icon or title for dropdown
*/
const toolboxSettings = currentBlock.tool.toolbox || {};
const toolboxSettings = await currentBlock.getActiveToolboxEntry() || {};
this.nodes.conversionTogglerContent.innerHTML =
toolboxSettings.icon ||

View file

@ -544,6 +544,7 @@ export default class UI extends Module<UINodes> {
if (this.Editor.Toolbar.toolbox.opened) {
this.Editor.Toolbar.toolbox.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
} else if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else if (this.Editor.ConversionToolbar.opened) {

View file

@ -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<IBlockTool> {
}
/**
* 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,
},
];
}
}
/**

View file

@ -3,7 +3,7 @@ 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, PopoverItem } from '../../../types';
import EventsDispatcher from '../utils/events';
import Popover, { PopoverEvent } from '../utils/popover';
import I18n from '../i18n';
@ -99,7 +99,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
private static get CSS(): { [name: string]: string } {
return {
toolbox: 'ce-toolbox',
toolboxOpenedTop: 'ce-toolbox--opened-top',
};
}
@ -128,21 +127,12 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
*/
public make(): Element {
this.popover = new Popover({
scopeElement: this.api.ui.nodes.redactor,
className: Toolbox.CSS.toolbox,
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<ToolboxEvent> {
* 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);
}
/**
@ -198,15 +189,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
return;
}
/**
* Open the popover above the button
* if there is not enough available space below it
*/
if (!this.shouldOpenPopoverBottom) {
this.nodes.toolbox.style.setProperty('--popover-height', this.popover.calculateHeight() + 'px');
this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpenedTop);
}
this.popover.show();
this.opened = true;
this.emit(ToolboxEvent.Opened);
@ -218,7 +200,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
public close(): void {
this.popover.hide();
this.opened = false;
this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop);
this.emit(ToolboxEvent.Closed);
}
@ -233,21 +214,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
}
}
/**
* Checks if there popover should be opened downwards.
* It happens in case there is enough space below or not enough space above
*/
private get shouldOpenPopoverBottom(): boolean {
const toolboxRect = this.nodes.toolbox.getBoundingClientRect();
const editorElementRect = this.api.ui.nodes.redactor.getBoundingClientRect();
const popoverHeight = this.popover.calculateHeight();
const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight;
const popoverPotentialTopEdge = toolboxRect.top - popoverHeight;
const bottomEdgeForComparison = Math.min(window.innerHeight, editorElementRect.bottom);
return popoverPotentialTopEdge < editorElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
}
/**
* Handles overlay click
*/
@ -262,24 +228,79 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
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,
onActivate: (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 +352,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
* 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<void> {
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
@ -346,9 +368,20 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
*/
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,

View file

@ -780,6 +780,25 @@ export const isIosDevice =
(/iP(ad|hone|od)/.test(window.navigator.platform) ||
(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;
}
/**
* Returns production of multiplication of arguments
*

View file

@ -5,39 +5,7 @@ import SearchInput from './search-input';
import EventsDispatcher from './events';
import { isMobileScreen, keyCodes, cacheable } from '../utils';
import ScrollLocker from './scroll-locker';
/**
* Describe parameters for rendering the single item of Popover
*/
export interface PopoverItem {
/**
* Item icon to be appeared near a title
*/
icon: string;
/**
* Displayed text
*/
label: string;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* Itm click handler
*
* @param item - clicked item
*/
onClick: (item: PopoverItem) => void;
}
import { PopoverItem, PopoverItemWithConfirmation } from '../../../types';
/**
* Event that can be triggered by the Popover
@ -47,17 +15,37 @@ export enum PopoverEvent {
* When popover overlay is clicked
*/
OverlayClicked = 'overlay-clicked',
/**
* When popover closes
*/
Close = 'close'
}
/**
* Popover is the UI element for displaying vertical lists
*/
export default class Popover extends EventsDispatcher<PopoverEvent> {
/**
* Flipper - module for keyboard iteration between elements
*/
public flipper: Flipper;
/**
* Items list to be displayed
*/
private readonly items: PopoverItem[];
/**
* Arbitrary html element to be inserted before items list
*/
private readonly customContent: HTMLElement;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
private readonly customContentFlippableItems: HTMLElement[] = [];
/**
* Stores the visibility state.
*/
@ -90,11 +78,6 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
*/
private listeners: Listeners;
/**
* Flipper - module for keyboard iteration between elements
*/
private flipper: Flipper;
/**
* Pass true to enable local search field
*/
@ -118,20 +101,27 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
/**
* Style classes
*/
private static get CSS(): {
public static get CSS(): {
popover: string;
popoverOpened: string;
itemsWrapper: string;
item: string;
itemHidden: string;
itemFocused: string;
itemActive: string;
itemDisabled: string;
itemLabel: string;
itemIcon: string;
itemSecondaryLabel: string;
itemConfirmation: string;
itemNoHover: string;
itemNoFocus: string;
noFoundMessage: string;
noFoundMessageShown: string;
popoverOverlay: string;
popoverOverlayHidden: string;
customContent: string;
customContentHidden: string;
} {
return {
popover: 'ce-popover',
@ -140,6 +130,11 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
item: 'ce-popover__item',
itemHidden: 'ce-popover__item--hidden',
itemFocused: 'ce-popover__item--focused',
itemActive: 'ce-popover__item--active',
itemDisabled: 'ce-popover__item--disabled',
itemConfirmation: 'ce-popover__item--confirmation',
itemNoHover: 'ce-popover__item--no-visible-hover',
itemNoFocus: 'ce-popover__item--no-visible-focus',
itemLabel: 'ce-popover__item-label',
itemIcon: 'ce-popover__item-icon',
itemSecondaryLabel: 'ce-popover__item-secondary-label',
@ -147,6 +142,8 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
noFoundMessageShown: 'ce-popover__no-found--shown',
popoverOverlay: 'ce-popover__overlay',
popoverOverlayHidden: 'ce-popover__overlay--hidden',
customContent: 'ce-popover__custom-content',
customContentHidden: 'ce-popover__custom-content--hidden',
};
}
@ -155,6 +152,16 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
*/
private scrollLocker = new ScrollLocker()
/**
* Editor container element
*/
private scopeElement: HTMLElement;
/**
* Stores data on popover items that are in confirmation state
*/
private itemsRequiringConfirmation: { [itemIndex: number]: PopoverItem } = {};
/**
* Creates the Popover
*
@ -163,19 +170,28 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
* @param options.className - additional class name to be added to the popover wrapper
* @param options.filterLabel - label for the search Field
* @param options.nothingFoundLabel - label of the 'nothing found' message
* @param options.customContent - arbitrary html element to be inserted before items list
* @param options.customContentFlippableItems - list of html elements inside custom content area that should be available for keyboard navigation
* @param options.scopeElement - editor container element
*/
constructor({ items, className, searchable, filterLabel, nothingFoundLabel }: {
constructor({ items, className, searchable, filterLabel, nothingFoundLabel, customContent, customContentFlippableItems, scopeElement }: {
items: PopoverItem[];
className?: string;
searchable?: boolean;
filterLabel: string;
nothingFoundLabel: string;
customContent?: HTMLElement;
customContentFlippableItems?: HTMLElement[];
scopeElement: HTMLElement;
}) {
super();
this.items = items;
this.customContent = customContent;
this.customContentFlippableItems = customContentFlippableItems;
this.className = className || '';
this.searchable = searchable;
this.listeners = new Listeners();
this.scopeElement = scopeElement;
this.filterLabel = filterLabel;
this.nothingFoundLabel = nothingFoundLabel;
@ -195,20 +211,32 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
* Shows the Popover
*/
public show(): void {
/**
* Open the popover above the button
* if there is not enough available space below it
*/
if (!this.shouldOpenPopoverBottom) {
this.nodes.wrapper.style.setProperty('--popover-height', this.calculateHeight() + 'px');
this.nodes.wrapper.classList.add(this.className + '--opened-top');
}
/**
* Clear search and items scrolling
*/
this.search.clear();
if (this.search) {
this.search.clear();
}
this.nodes.items.scrollTop = 0;
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden);
this.flipper.activate();
this.flipper.activate(this.flippableElements);
if (this.searchable) {
window.requestAnimationFrame(() => {
setTimeout(() => {
this.search.focus();
});
}, 100);
}
if (isMobileScreen()) {
@ -239,13 +267,31 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
}
this.isShown = false;
this.nodes.wrapper.classList.remove(this.className + '--opened-top');
/**
* Remove confirmation state from items
*/
const confirmationStateItems = Array.from(this.nodes.items.querySelectorAll(`.${Popover.CSS.itemConfirmation}`));
confirmationStateItems.forEach((itemEl: HTMLElement) => this.cleanUpConfirmationStateForItem(itemEl));
this.disableSpecialHoverAndFocusBehavior();
this.emit(PopoverEvent.Close);
}
/**
* Clears memory
*/
public destroy(): void {
this.flipper.deactivate();
this.listeners.removeAll();
this.disableSpecialHoverAndFocusBehavior();
if (isMobileScreen()) {
this.scrollLocker.unlock();
}
}
/**
@ -260,7 +306,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
* Renders invisible clone of popover to get actual height.
*/
@cacheable
public calculateHeight(): number {
private calculateHeight(): number {
let height = 0;
const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement;
@ -290,6 +336,11 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
this.addSearch(this.nodes.popover);
}
if (this.customContent) {
this.customContent.classList.add(Popover.CSS.customContent);
this.nodes.popover.appendChild(this.customContent);
}
this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper);
this.items.forEach(item => {
this.nodes.items.appendChild(this.createItem(item));
@ -302,11 +353,11 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
this.nodes.popover.appendChild(this.nodes.nothingFound);
this.listeners.on(this.nodes.popover, 'click', (event: KeyboardEvent|MouseEvent) => {
this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => {
const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement;
if (clickedItem) {
this.itemClicked(clickedItem);
this.itemClicked(clickedItem, event as PointerEvent);
}
});
@ -325,27 +376,43 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
items: this.items,
placeholder: this.filterLabel,
onSearch: (filteredItems): void => {
const itemsVisible = [];
const searchResultElements = [];
this.items.forEach((item, index) => {
const itemElement = this.nodes.items.children[index];
if (filteredItems.includes(item)) {
itemsVisible.push(itemElement);
searchResultElements.push(itemElement);
itemElement.classList.remove(Popover.CSS.itemHidden);
} else {
itemElement.classList.add(Popover.CSS.itemHidden);
}
});
this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, itemsVisible.length === 0);
this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, searchResultElements.length === 0);
/**
* Update flipper items with only visible
* In order to make keyboard navigation work correctly, flipper should be reactivated with only visible items.
* As custom html content is not displayed while search, it should be excluded from keyboard navigation.
*/
this.flipper.deactivate();
this.flipper.activate(itemsVisible);
this.flipper.focusFirst();
const allItemsDisplayed = filteredItems.length === this.items.length;
/**
* Contains list of elements available for keyboard navigation considering search query applied
*/
const flippableElements = allItemsDisplayed ? this.flippableElements : searchResultElements;
if (this.customContent) {
this.customContent.classList.toggle(Popover.CSS.customContentHidden, !allItemsDisplayed);
}
if (this.flipper.isActivated) {
/**
* Update flipper items with only visible
*/
this.reactivateFlipper(flippableElements);
this.flipper.focusFirst();
}
},
});
@ -362,7 +429,9 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
private createItem(item: PopoverItem): HTMLElement {
const el = Dom.make('div', Popover.CSS.item);
el.dataset.itemName = item.name;
if (item.name) {
el.dataset.itemName = item.name;
}
const label = Dom.make('div', Popover.CSS.itemLabel, {
innerHTML: item.label,
});
@ -381,6 +450,14 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
}));
}
if (item.isActive) {
el.classList.add(Popover.CSS.itemActive);
}
if (item.isDisabled) {
el.classList.add(Popover.CSS.itemDisabled);
}
return el;
}
@ -388,23 +465,182 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
* Item click handler
*
* @param itemEl - clicked item
* @param event - click event
*/
private itemClicked(itemEl: HTMLElement): void {
const allItems = this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`);
const itemIndex = Array.from(allItems).indexOf(itemEl);
private itemClicked(itemEl: HTMLElement, event: PointerEvent): void {
const allItems = Array.from(this.nodes.items.children);
const itemIndex = allItems.indexOf(itemEl);
const clickedItem = this.items[itemIndex];
clickedItem.onClick(clickedItem);
if (clickedItem.isDisabled) {
return;
}
/**
* If there is any other item in confirmation state except the clicked one, clean it up
*/
allItems
.filter(item => item !== itemEl)
.forEach(item => {
this.cleanUpConfirmationStateForItem(item);
});
if (clickedItem.confirmation) {
this.enableConfirmationStateForItem(clickedItem as PopoverItemWithConfirmation, itemEl, itemIndex);
return;
}
clickedItem.onActivate(clickedItem, event);
if (clickedItem.toggle) {
clickedItem.isActive = !clickedItem.isActive;
itemEl.classList.toggle(Popover.CSS.itemActive);
}
if (clickedItem.closeOnActivate) {
this.hide();
}
}
/**
* Enables confirmation state for specified item.
* Replaces item element in popover so that is becomes highlighted in a special way
*
* @param item - item to enable confirmation state for
* @param itemEl - html element corresponding to the item
* @param itemIndex - index of the item in all items list
*/
private enableConfirmationStateForItem(item: PopoverItemWithConfirmation, itemEl: HTMLElement, itemIndex: number): void {
/** Save root item requiring confirmation to restore original state on popover hide */
if (this.itemsRequiringConfirmation[itemIndex] === undefined) {
this.itemsRequiringConfirmation[itemIndex] = item;
}
const newItemData = {
...item,
...item.confirmation,
confirmation: item.confirmation.confirmation,
} as PopoverItem;
this.items[itemIndex] = newItemData;
const confirmationStateItemEl = this.createItem(newItemData as PopoverItem);
confirmationStateItemEl.classList.add(Popover.CSS.itemConfirmation, ...Array.from(itemEl.classList));
itemEl.parentElement.replaceChild(confirmationStateItemEl, itemEl);
this.enableSpecialHoverAndFocusBehavior(confirmationStateItemEl);
this.reactivateFlipper(
this.flippableElements,
this.flippableElements.indexOf(confirmationStateItemEl)
);
}
/**
* Brings specified element corresponding to popover item to its original state
*
* @param itemEl - item in confirmation state
*/
private cleanUpConfirmationStateForItem(itemEl: Element): void {
const allItems = Array.from(this.nodes.items.children);
const index = allItems.indexOf(itemEl);
const originalItem = this.itemsRequiringConfirmation[index];
if (originalItem === undefined) {
return;
}
const originalStateItemEl = this.createItem(originalItem);
itemEl.parentElement.replaceChild(originalStateItemEl, itemEl);
this.items[index] = originalItem;
delete this.itemsRequiringConfirmation[index];
itemEl.removeEventListener('mouseleave', this.removeSpecialHoverBehavior);
this.disableSpecialHoverAndFocusBehavior();
this.reactivateFlipper(
this.flippableElements,
this.flippableElements.indexOf(originalStateItemEl)
);
}
/**
* Enables special focus and hover behavior for item in confirmation state.
* This is needed to prevent item from being highlighted as hovered/focused just after click.
*
* @param item - html element of the item to enable special behavior for
*/
private enableSpecialHoverAndFocusBehavior(item: HTMLElement): void {
item.classList.add(Popover.CSS.itemNoHover);
item.classList.add(Popover.CSS.itemNoFocus);
item.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true });
this.flipper.onFlip(this.onFlip);
}
/**
* Disables special focus and hover behavior.
*/
private disableSpecialHoverAndFocusBehavior(): void {
this.removeSpecialFocusBehavior();
this.removeSpecialHoverBehavior();
this.flipper.removeOnFlip(this.onFlip);
}
/**
* Removes class responsible for special hover behavior on an item
*/
private removeSpecialHoverBehavior = (): void => {
const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoHover}`);
if (!el) {
return;
}
el.classList.remove(Popover.CSS.itemNoHover);
}
/**
* Removes class responsible for special focus behavior on an item
*/
private removeSpecialFocusBehavior(): void {
const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoFocus}`);
if (!el) {
return;
}
el.classList.remove(Popover.CSS.itemNoFocus);
}
/**
* Called on flipper navigation
*/
private onFlip = (): void => {
this.disableSpecialHoverAndFocusBehavior();
}
/**
* Reactivates flipper instance.
* Should be used if popover items html elements get replaced to preserve workability of keyboard navigation
*
* @param items - html elements to navigate through
* @param focusedIndex - index of element to be focused
*/
private reactivateFlipper(items: HTMLElement[], focusedIndex?: number): void {
this.flipper.deactivate();
this.flipper.activate(items, focusedIndex);
}
/**
* Creates Flipper instance to be able to leaf tools
*/
private enableFlipper(): void {
const tools = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[];
this.flipper = new Flipper({
items: tools,
items: this.flippableElements,
focusedItemClass: Popover.CSS.itemFocused,
allowedKeys: [
keyCodes.TAB,
@ -414,4 +650,37 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
],
});
}
/**
* Returns list of elements available for keyboard navigation.
* Contains both usual popover items elements and custom html content.
*/
private get flippableElements(): HTMLElement[] {
/**
* Select html elements of popover items
*/
const popoverItemsElements = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[];
const customContentControlsElements = this.customContentFlippableItems || [];
/**
* Combine elements inside custom content area with popover items elements
*/
return customContentControlsElements.concat(popoverItemsElements);
}
/**
* Checks if popover should be opened bottom.
* It should happen when there is enough space below or not enough space above
*/
private get shouldOpenPopoverBottom(): boolean {
const toolboxRect = this.nodes.wrapper.getBoundingClientRect();
const scopeElementRect = this.scopeElement.getBoundingClientRect();
const popoverHeight = this.calculateHeight();
const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight;
const popoverPotentialTopEdge = toolboxRect.top - popoverHeight;
const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom);
return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
}
}

View file

@ -36,18 +36,11 @@
/**
* Settings
* @deprecated - use tunes config instead of creating html element with controls
*/
.cdx-settings-button {
@apply --toolbar-button;
&:not(:nth-child(3n+3)) {
margin-right: 3px;
}
&:nth-child(n+4) {
margin-top: 3px;
}
&--active {
color: var(--color-active-icon);
}

View file

@ -6,10 +6,12 @@
flex-direction: column;
padding: 6px;
min-width: 200px;
width: 200px;
overflow: hidden;
box-sizing: border-box;
flex-shrink: 0;
max-height: 0;
pointer-events: none;
@apply --overlay-pane;
@ -19,6 +21,7 @@
&--opened {
opacity: 1;
max-height: 270px;
pointer-events: auto;
animation: panelShowing 100ms ease;
@media (--mobile) {
@ -40,12 +43,14 @@
}
@media (--mobile) {
--offset: 5px;
position: fixed;
max-width: none;
min-width: auto;
left: 5px;
right: 5px;
bottom: calc(5px + env(safe-area-inset-bottom));
min-width: calc(100% - var(--offset) * 2);
left: var(--offset);
right: var(--offset);
bottom: calc(var(--offset) + env(safe-area-inset-bottom));
top: auto;
border-radius: 10px;
}
@ -62,19 +67,77 @@
&__item {
@apply --popover-button;
@media (--can-hover) {
&:hover {
&:not(.ce-popover__item--no-visible-hover) {
background-color: var(--bg-light);
}
.ce-popover__item-icon {
box-shadow: none;
}
}
}
&--disabled {
@apply --button-disabled;
.ce-popover__item-icon {
box-shadow: 0 0 0 1px var(--color-line-gray);
}
}
&--focused {
@apply --button-focused;
&:not(.ce-popover__item--no-visible-focus) {
@apply --button-focused;
}
}
&--hidden {
display: none;
}
&--active {
@apply --button-active;
}
&--confirmation {
background: var(--color-confirm);
.ce-popover__item-icon {
color: var(--color-confirm);
}
.ce-popover__item-label {
color: white;
}
&:not(.ce-popover__item--no-visible-hover) {
@media (--can-hover) {
&:hover {
background: var(--color-confirm-hover);
}
}
}
&:not(.ce-popover__item--no-visible-focus) {
&.ce-popover__item--focused {
background: var(--color-confirm-hover) !important;
}
}
}
&-icon {
@apply --tool-icon;
}
&-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&::after {
content: '';
width: 25px;
@ -96,6 +159,12 @@
display: none;
}
}
&--confirmation, &--active, &--focused {
.ce-popover__item-icon {
box-shadow: none;
}
}
}
&__no-found {
@ -108,10 +177,6 @@
&--shown {
display: block;
}
&:hover {
background-color: transparent;
}
}
@media (--mobile) {
@ -139,4 +204,17 @@
opacity: 0;
visibility: hidden;
}
&__custom-content:not(:empty) {
padding: 4px;
@media (--not-mobile) {
margin-top: 5px;
padding: 0;
}
}
&__custom-content--hidden {
display: none;
}
}

View file

@ -1,43 +1,15 @@
.ce-settings {
@apply --overlay-pane;
top: var(--toolbar-buttons-size);
left: 0;
min-width: 114px;
box-sizing: content-box;
position: absolute;
z-index: 2;
--gap: 8px;
@media (--mobile){
bottom: 40px;
right: auto;
top: auto;
}
@media (--not-mobile){
position: absolute;
top: calc(var(--toolbox-buttons-size) + var(--gap));
left: 0;
&::before{
left: auto;
right: 12px;
@media (--mobile){
bottom: -5px;
top: auto;
}
}
display: none;
&--opened {
display: block;
animation-duration: 0.1s;
animation-name: panelShowing;
}
&__plugin-zone {
&:not(:empty){
padding: 3px 3px 0;
}
}
&__default-zone {
&:not(:empty){
padding: 3px;
&--opened-top {
top: calc(-1 * (var(--gap) + var(--popover-height)));
}
}

View file

@ -34,6 +34,7 @@
/**
* Gray border, loaders
* @deprecated use --color-line-gray instead
*/
--color-gray-border: rgba(201, 201, 204, 0.48);
@ -69,6 +70,9 @@
* Confirm deletion bg
*/
--color-confirm: #E24A4A;
--color-confirm-hover: #CE4343;
--color-line-gray: #EFF0F1;
--overlay-pane: {
position: absolute;
@ -104,6 +108,17 @@
background: rgba(34, 186, 255, 0.08) !important;
};
--button-active: {
background: rgba(56, 138, 229, 0.1);
color: var(--color-active-icon);
};
--button-disabled: {
color: var(--grayText);
cursor: default;
pointer-events: none;
}
/**
* Styles for Toolbox Buttons and Plus Button
*/
@ -197,12 +212,6 @@
margin-bottom: 1px;
}
@media (--can-hover) {
&:hover {
background-color: var(--bg-light);
}
}
@media (--mobile) {
font-size: 16px;
padding: 4px;
@ -216,12 +225,12 @@
display: inline-flex;
width: var(--toolbox-buttons-size);
height: var(--toolbox-buttons-size);
border: 1px solid var(--color-gray-border);
box-shadow: 0 0 0 1px var(--color-gray-border);
border-radius: 5px;
align-items: center;
justify-content: center;
background: #fff;
box-sizing: border-box;
box-sizing: content-box;
flex-shrink: 0;
margin-right: 10px;

View file

@ -0,0 +1,493 @@
import { ToolboxConfig, BlockToolData, ToolboxConfigEntry } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
/* eslint-disable @typescript-eslint/no-empty-function */
const ICON = '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"></path></svg>';
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);
});
});
context('Tunes', () => {
it('should contain a single block tune configured in tool\'s renderSettings() method', () => {
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): TunesMenuConfig {
return {
label: 'Test tool tune',
icon: ICON,
name: 'testToolTune',
onActivate: (): void => {},
};
}
/** Save method stub */
public save(): void {}
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
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();
// Insert test tool block
cy.get('[data-cy=editorjs]')
.get(`[data-item-name="testTool"]`)
.click();
cy.get('[data-cy=editorjs]')
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
// Expect preconfigured tune to exist in tunes menu
cy.get('[data-item-name=testToolTune]').should('exist');
});
it('should contain multiple block tunes if configured in tool\'s renderSettings() method', () => {
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): TunesMenuConfig {
return [
{
label: 'Test tool tune 1',
icon: ICON,
name: 'testToolTune1',
onActivate: (): void => {},
},
{
label: 'Test tool tune 2',
icon: ICON,
name: 'testToolTune2',
onActivate: (): void => {},
},
];
}
/** Save method stub */
public save(): void {}
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
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();
// Insert test tool block
cy.get('[data-cy=editorjs]')
.get(`[data-item-name="testTool"]`)
.click();
cy.get('[data-cy=editorjs]')
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
// Expect preconfigured tunes to exist in tunes menu
cy.get('[data-item-name=testToolTune1]').should('exist');
cy.get('[data-item-name=testToolTune2]').should('exist');
});
it('should contain block tunes represented as custom html if so configured in tool\'s renderSettings() method', () => {
const sampleText = 'sample text';
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): HTMLElement {
const element = document.createElement('div');
element.textContent = sampleText;
return element;
}
/** Save method stub */
public save(): void {}
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
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();
// Insert test tool block
cy.get('[data-cy=editorjs]')
.get(`[data-item-name="testTool"]`)
.click();
cy.get('[data-cy=editorjs]')
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
// Expect preconfigured custom html tunes to exist in tunes menu
cy.get('[data-cy=editorjs]')
.get('.ce-popover')
.should('contain.text', sampleText);
});
});
});

View file

@ -0,0 +1,136 @@
import { TunesMenuConfig } from '../../../../types/tools';
/* eslint-disable @typescript-eslint/no-empty-function */
describe('Editor Tunes Api', () => {
it('should render a popover entry for block tune if configured', () => {
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): TunesMenuConfig {
return {
icon: 'ICON',
label: 'Test tune',
name: 'testTune',
onActivate: (): void => { },
};
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.type('some text')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
cy.get('[data-item-name=testTune]').should('exist');
});
it('should render several popover entries for block tune if configured', () => {
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): TunesMenuConfig {
return [
{
icon: 'ICON1',
label: 'Tune entry 1',
name: 'testTune1',
onActivate: (): void => { },
}, {
icon: 'ICON2',
label: 'Tune entry 2',
name: 'testTune2',
onActivate: (): void => { },
},
];
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.type('some text')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
cy.get('[data-item-name=testTune1]').should('exist');
cy.get('[data-item-name=testTune2]').should('exist');
});
it('should display custom html returned by tune\'s render() method inside tunes menu', () => {
const sampleText = 'sample text';
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): HTMLElement {
const element = document.createElement('div');
element.textContent = sampleText;
return element;
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.type('some text')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-popover')
.should('contain.text', sampleText);
});
});

View file

@ -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: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>',
};
}
}
describe('Editor i18n', () => {
context('Toolbox', () => {
it('should translate tool title in a toolbox', function () {
@ -50,10 +35,85 @@ describe('Editor i18n', () => {
.should('contain.text', toolNamesDictionary.Heading);
});
it('should use capitalized tool name as translation key if toolbox title is missing', function () {
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: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>',
};
}
}
const toolNamesDictionary = {
TestTool: 'ТестТул',
};

View file

@ -262,8 +262,12 @@ describe('onChange callback', () => {
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-settings__button--delete')
.click()
.get('div[data-item-name=delete]')
.click();
/** Second click for confirmation */
cy.get('[data-cy=editorjs]')
.get('div[data-item-name=delete]')
.click();
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
@ -292,7 +296,7 @@ describe('onChange callback', () => {
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-tune-move-up')
.get('div[data-item-name=move-up]')
.click();
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({

View file

@ -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', () => {

View file

@ -0,0 +1,97 @@
import { PopoverItem } from '../../../../types/index.js';
/**
* Mock of some Block Tool
*/
class SomePlugin {
/**
* Event handler to be spyed in test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public static pluginInternalKeydownHandler(): void {}
/**
* Mocked render method
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.classList.add('cdx-some-plugin');
wrapper.contentEditable = 'true';
wrapper.addEventListener('keydown', SomePlugin.pluginInternalKeydownHandler);
return wrapper;
}
/**
* Used to display our tool in the Toolboz
*/
public static get toolbox(): PopoverItem {
return {
icon: '₷',
label: 'Some tool',
// eslint-disable-next-line @typescript-eslint/no-empty-function
onActivate: (): void => {},
};
}
}
describe('Flipper', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({
tools: {
sometool: SomePlugin,
},
}).as('editorInstance');
}
});
it('should prevent plugins event handlers from being called while keyboard navigation', () => {
const TAB_KEY_CODE = 9;
const ARROW_DOWN_KEY_CODE = 40;
const ENTER_KEY_CODE = 13;
const sampleText = 'sample text';
cy.spy(SomePlugin, 'pluginInternalKeydownHandler');
// Insert sometool block and enter sample text
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.trigger('keydown', { keyCode: TAB_KEY_CODE });
cy.get('[data-item-name=sometool]').click();
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
.focus()
.type(sampleText);
// Try to delete the block via keyboard
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
// Open tunes menu
.trigger('keydown', { keyCode: TAB_KEY_CODE })
// Navigate to delete button (the second button)
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE })
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE });
/**
* Check whether we focus the Delete Tune or not
*/
cy.get('[data-item-name="delete"]')
.should('have.class', 'ce-popover__item--focused');
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
// Click delete
.trigger('keydown', { keyCode: ENTER_KEY_CODE })
// // Confirm delete
.trigger('keydown', { keyCode: ENTER_KEY_CODE });
expect(SomePlugin.pluginInternalKeydownHandler).to.have.not.been.called;
});
});

View file

@ -0,0 +1,188 @@
import Popover from '../../../../src/components/utils/popover';
import { PopoverItem } from '../../../../types';
/* eslint-disable @typescript-eslint/no-empty-function */
describe('Popover', () => {
it('should support confirmation chains', () => {
const actionIcon = 'Icon 1';
const actionLabel = 'Action';
const confirmActionIcon = 'Icon 2';
const confirmActionLabel = 'Confirm action';
/**
* Confirmation is moved to separate variable to be able to test it's callback execution.
* (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise)
*/
const confirmation = {
icon: confirmActionIcon,
label: confirmActionLabel,
onActivate: cy.stub(),
};
const items: PopoverItem[] = [
{
icon: actionIcon,
label: actionLabel,
name: 'testItem',
confirmation,
},
];
const popover = new Popover({
items,
filterLabel: '',
nothingFoundLabel: '',
scopeElement: null,
});
cy.document().then(doc => {
doc.body.append(popover.getElement());
cy.get('[data-item-name=testItem]')
.get('.ce-popover__item-icon')
.should('have.text', actionIcon);
cy.get('[data-item-name=testItem]')
.get('.ce-popover__item-label')
.should('have.text', actionLabel);
// First click on item
cy.get('[data-item-name=testItem]').click();
// Check icon has changed
cy.get('[data-item-name=testItem]')
.get('.ce-popover__item-icon')
.should('have.text', confirmActionIcon);
// Check label has changed
cy.get('[data-item-name=testItem]')
.get('.ce-popover__item-label')
.should('have.text', confirmActionLabel);
// Second click
cy.get('[data-item-name=testItem]')
.click()
.then(() => {
// Check onActivate callback has been called
expect(confirmation.onActivate).to.have.been.calledOnce;
});
});
});
it('should render the items with true isActive property value as active', () => {
const items: PopoverItem[] = [
{
icon: 'Icon',
label: 'Label',
isActive: true,
name: 'testItem',
onActivate: (): void => {},
},
];
const popover = new Popover({
items,
filterLabel: '',
nothingFoundLabel: '',
scopeElement: null,
});
cy.document().then(doc => {
doc.body.append(popover.getElement());
/* Check item has active class */
cy.get('[data-item-name=testItem]')
.should('have.class', 'ce-popover__item--active');
});
});
it('should not execute item\'s onActivate callback if the item is disabled', () => {
const items: PopoverItem[] = [
{
icon: 'Icon',
label: 'Label',
isDisabled: true,
name: 'testItem',
onActivate: cy.stub(),
},
];
const popover = new Popover({
items,
filterLabel: '',
nothingFoundLabel: '',
scopeElement: null,
});
cy.document().then(doc => {
doc.body.append(popover.getElement());
/* Check item has disabled class */
cy.get('[data-item-name=testItem]')
.should('have.class', 'ce-popover__item--disabled')
.click()
.then(() => {
// Check onActivate callback has never been called
expect(items[0].onActivate).to.have.not.been.called;
});
});
});
it('should close once item with closeOnActivate property set to true is activated', () => {
const items: PopoverItem[] = [
{
icon: 'Icon',
label: 'Label',
closeOnActivate: true,
name: 'testItem',
onActivate: (): void => {},
},
];
const popover = new Popover({
items,
filterLabel: '',
nothingFoundLabel: '',
scopeElement: null,
});
cy.spy(popover, 'hide');
cy.document().then(doc => {
doc.body.append(popover.getElement());
cy.get('[data-item-name=testItem]')
.click()
.then(() => {
expect(popover.hide).to.have.been.called;
});
});
});
it('should highlight as active the item with toggle property set to true once activated', () => {
const items: PopoverItem[] = [
{
icon: 'Icon',
label: 'Label',
toggle: true,
name: 'testItem',
onActivate: (): void => {},
},
];
const popover = new Popover({
items,
filterLabel: '',
nothingFoundLabel: '',
scopeElement: null,
});
cy.document().then(doc => {
doc.body.append(popover.getElement());
/* Check item has active class */
cy.get('[data-item-name=testItem]')
.click()
.should('have.class', 'ce-popover__item--active');
});
});
});

View file

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "es2017", "es2018"],
"lib": ["dom", "es2017", "es2018", "es2019"],
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,

View file

@ -4,7 +4,7 @@
"target": "es2017",
"declaration": false,
"moduleResolution": "node", // This resolution strategy attempts to mimic the Node.js module resolution mechanism at runtime
"lib": ["dom", "es2017", "es2018", "ES2019", "ES2022"],
"lib": ["dom", "es2017", "es2018", "es2019"],
// allows to import .json files for i18n
"resolveJsonModule": true,

View file

@ -52,7 +52,7 @@ export interface Blocks {
* Returns Block API object by passed Block index
* @param {number} index
*/
getBlockByIndex(index: number): BlockAPI | void;
getBlockByIndex(index: number): BlockAPI | undefined;
/**
* Returns Block API object by passed Block id
@ -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<BlockToolData>
/**
* Updates block data by id
*

View file

@ -1,5 +1,6 @@
import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index';
import { BlockTuneData } from './block-tune-data';
import { TunesMenuConfig } from '../tools';
/**
* Describes BLockTune blueprint
@ -7,10 +8,8 @@ import { BlockTuneData } from './block-tune-data';
export interface BlockTune {
/**
* Returns block tune HTMLElement
*
* @return {HTMLElement}
*/
render(): HTMLElement;
render(): HTMLElement | TunesMenuConfig;
/**
* Method called on Tool render. Pass Tool content as an argument.

View file

@ -1,3 +1,5 @@
import { fromCallback } from 'cypress/types/bluebird';
export * from './editor-config';
export * from './sanitizer-config';
export * from './paste-config';
@ -5,3 +7,4 @@ export * from './conversion-config';
export * from './log-levels';
export * from './i18n-config';
export * from './i18n-dictionary';
export * from './popover'

79
types/configs/popover.d.ts vendored Normal file
View file

@ -0,0 +1,79 @@
/**
* Common parameters for both types of popover items: with or without confirmation
*/
interface PopoverItemBase {
/**
* Item icon to be appeared near a title
*/
icon: string;
/**
* Displayed text
*/
label: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* True if item should be highlighted as active
*/
isActive?: boolean;
/**
* True if item should be disabled
*/
isDisabled?: boolean;
/**
* True if popover should close once item is activated
*/
closeOnActivate?: boolean;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* True if item should be highlighted once activated
*/
toggle?: boolean;
}
/**
* Represents popover item with confirmation state configuration
*/
export interface PopoverItemWithConfirmation extends PopoverItemBase {
/**
* Popover item parameters that should be applied on item activation.
* May be used to ask user for confirmation before executing popover item activation handler.
*/
confirmation: Partial<PopoverItem>;
onActivate?: never;
}
/**
* Represents default popover item without confirmation state configuration
*/
export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
confirmation?: never;
/**
* Popover item activation handler
*
* @param item - activated item
* @param event - event that initiated item activation
*/
onActivate: (item: PopoverItem, event?: PointerEvent) => void;
}
/**
* Represents single popover item
*/
export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation

4
types/index.d.ts vendored
View file

@ -48,6 +48,7 @@ export {
Tool,
ToolConstructable,
ToolboxConfig,
ToolboxConfigEntry,
ToolSettings,
ToolConfig,
PasteEvent,
@ -70,6 +71,9 @@ export {
Dictionary,
DictValue,
I18nConfig,
PopoverItem,
PopoverItemWithConfirmation,
PopoverItemWithoutConfirmation
} from './configs';
export {OutputData, OutputBlockData} from './data-formats/output-data';
export { BlockAPI } from './api'

View file

@ -1,10 +1,11 @@
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';
import { TunesMenuConfig } from './tool-settings';
/**
* Describe Block Tool object
@ -25,9 +26,8 @@ export interface BlockTool extends BaseTool {
/**
* Create Block's settings block
* @return {HTMLElement}
*/
renderSettings?(): HTMLElement;
renderSettings?(): HTMLElement | TunesMenuConfig;
/**
* Validate Block's data
@ -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

View file

@ -1,10 +1,17 @@
import {ToolConfig} from './tool-config';
import {ToolConstructable} from './index';
import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index';
import { PopoverItem } from '../configs';
/**
* 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,8 +21,19 @@ export interface ToolboxConfig {
* HTML string with an icon for Toolbox
*/
icon?: string;
/**
* May contain overrides for tool default config
*/
data?: BlockToolData
}
/**
* Tool may specify its tunes configuration
* that can contain either one or multiple entries
*/
export type TunesMenuConfig = PopoverItem | PopoverItem[];
/**
* Object passed to the Tool's constructor by {@link EditorConfig#tools}
*

View file

@ -2411,8 +2411,9 @@ buffer-crc32@~0.2.3:
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer-xor@^1.0.3:
version "1.0.3"
@ -7876,8 +7877,9 @@ source-map-resolve@^0.5.0:
urix "^0.1.0"
source-map-support@^0.5.16, source-map-support@~0.5.12:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
@ -7893,6 +7895,7 @@ source-map@^0.5.0, source-map@^0.5.6, source-map@~0.5.3:
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
spawn-wrap@^2.0.0:
version "2.0.0"
@ -8369,8 +8372,9 @@ terser-webpack-plugin@^2.3.6:
webpack-sources "^1.4.3"
terser@^4.1.2, terser@^4.6.12:
version "4.6.12"
resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.12.tgz#44b98aef8703fdb09a3491bf79b43faffc5b4fee"
version "4.8.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f"
integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==
dependencies:
commander "^2.20.0"
source-map "~0.6.1"