diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 5d37082a..4b45ece3 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+
+### 2.26.0
+
+- `New` — *UI* — Meet the new Block Tunes look! Vertical menu with simple JSON configuration (and support for legacy way of defining block tunes).
+- `New` — *Block Tunes API* — Now `render()` method of a Block Tune can return `TunesMenuConfig` besides `HTMLElement`. 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 `TunesMenuConfig` return value 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` — Prevent flipper from handling the event which caused it's instantiating.
+
### 2.25.0
- `New` — *Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool!
diff --git a/example/example-i18n.html b/example/example-i18n.html
index c123bd7b..930dbaef 100644
--- a/example/example-i18n.html
+++ b/example/example-i18n.html
@@ -194,9 +194,11 @@
"toolbar": {
"toolbox": {
"Add": "Добавить",
- "Filter": "Поиск",
- "Nothing found": "Ничего не найдено"
}
+ },
+ "popover": {
+ "Filter": "Поиск",
+ "Nothing found": "Ничего не найдено"
}
},
diff --git a/package.json b/package.json
index ddc46344..8478ab8c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
- "version": "2.25.0",
+ "version": "2.26.0",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editor.js",
"types": "./types/index.d.ts",
diff --git a/src/components/block-tunes/block-tune-delete.ts b/src/components/block-tunes/block-tune-delete.ts
index a9b4c08a..27bd612d 100644
--- a/src/components/block-tunes/block-tune-delete.ts
+++ b/src/components/block-tunes/block-tune-delete.ts
@@ -4,7 +4,7 @@
*
* @copyright 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();
}
}
diff --git a/src/components/block-tunes/block-tune-move-down.ts b/src/components/block-tunes/block-tune-move-down.ts
index 2eadbf8e..614a778f 100644
--- a/src/components/block-tunes/block-tune-move-down.ts
+++ b/src/components/block-tunes/block-tune-move-down.ts
@@ -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();
}
}
diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts
index b29ba6c3..1d07e3bf 100644
--- a/src/components/block-tunes/block-tune-move-up.ts
+++ b/src/components/block-tunes/block-tune-move-up.ts
@@ -5,7 +5,8 @@
* @copyright 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();
}
}
diff --git a/src/components/block/index.ts b/src/components/block/index.ts
index a9306c66..d2007f6a 100644
--- a/src/components/block/index.ts
+++ b/src/components/block/index.ts
@@ -5,7 +5,8 @@ import {
BlockTune as IBlockTune,
SanitizerConfig,
ToolConfig,
- ToolboxConfigEntry
+ ToolboxConfigEntry,
+ PopoverItem
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@@ -642,22 +643,33 @@ export default class Block extends EventsDispatcher {
}
/**
- * 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];
}
/**
@@ -726,15 +738,6 @@ export default class Block extends EventsDispatcher {
}
}
- /**
- * Call Tool instance renderSettings method
- */
- public renderSettings(): HTMLElement | undefined {
- if (_.isFunction(this.toolInstance.renderSettings)) {
- return this.toolInstance.renderSettings();
- }
- }
-
/**
* 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)
diff --git a/src/components/domIterator.ts b/src/components/domIterator.ts
index 339e70d5..d11d3b4d 100644
--- a/src/components/domIterator.ts
+++ b/src/components/domIterator.ts
@@ -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
*
diff --git a/src/components/flipper.ts b/src/components/flipper.ts
index 7ab00fbb..ed2df15f 100644
--- a/src/components/flipper.ts
+++ b/src/components/flipper.ts
@@ -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());
}
}
diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json
index d44df52c..32761be6 100644
--- a/src/components/i18n/locales/en/messages.json
+++ b/src/components/i18n/locales/en/messages.json
@@ -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": ""
diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts
index 8d742c85..57e7b6d5 100644
--- a/src/components/modules/api/blocks.ts
+++ b/src/components/modules/api/blocks.ts
@@ -23,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),
@@ -77,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) {
diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts
index 7fece2f4..7aa62018 100644
--- a/src/components/modules/blockEvents.ts
+++ b/src/components/modules/blockEvents.ts
@@ -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();
}
}
diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts
index 30f84633..16f72e36 100644
--- a/src/components/modules/toolbar/blockSettings.ts
+++ b/src/components/modules/toolbar/blockSettings.ts
@@ -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 {
@@ -42,82 +35,50 @@ export default class BlockSettings extends Module {
/**
* 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 {
* @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 {
/**
* 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 {
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(
`.${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();
}
}
diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts
index 9a041766..5c469fa0 100644
--- a/src/components/modules/toolbar/index.ts
+++ b/src/components/modules/toolbar/index.ts
@@ -388,7 +388,7 @@ export default class Toolbar extends Module {
* 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
@@ -407,8 +407,8 @@ export default class Toolbar extends Module {
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'),
},
});
diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts
index 7ae48c58..e213ac9a 100644
--- a/src/components/ui/toolbox.ts
+++ b/src/components/ui/toolbox.ts
@@ -3,9 +3,9 @@ import { BlockToolAPI } from '../block';
import Shortcuts from '../utils/shortcuts';
import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
-import { API, BlockToolData, ToolboxConfigEntry } from '../../../types';
+import { API, BlockToolData, ToolboxConfigEntry, PopoverItem } from '../../../types';
import EventsDispatcher from '../utils/events';
-import Popover, { PopoverEvent, PopoverItem } from '../utils/popover';
+import Popover, { PopoverEvent } from '../utils/popover';
import I18n from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
@@ -99,7 +99,6 @@ export default class Toolbox extends EventsDispatcher {
private static get CSS(): { [name: string]: string } {
return {
toolbox: 'ce-toolbox',
- toolboxOpenedTop: 'ce-toolbox--opened-top',
};
}
@@ -128,6 +127,7 @@ export default class Toolbox extends EventsDispatcher {
*/
public make(): Element {
this.popover = new Popover({
+ scopeElement: this.api.ui.nodes.redactor,
className: Toolbox.CSS.toolbox,
searchable: true,
filterLabel: this.i18nLabels.filter,
@@ -189,15 +189,6 @@ export default class Toolbox extends EventsDispatcher {
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);
@@ -209,7 +200,6 @@ export default class Toolbox extends EventsDispatcher {
public close(): void {
this.popover.hide();
this.opened = false;
- this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop);
this.emit(ToolboxEvent.Closed);
}
@@ -224,21 +214,6 @@ export default class Toolbox extends EventsDispatcher {
}
}
- /**
- * 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
*/
@@ -284,7 +259,7 @@ export default class Toolbox extends EventsDispatcher {
icon: toolboxItem.icon,
label: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
name: tool.name,
- onClick: (e): void => {
+ onActivate: (e): void => {
this.toolButtonActivated(tool.name, toolboxItem.data);
},
secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
diff --git a/src/components/utils/popover.ts b/src/components/utils/popover.ts
index 0a2c30c7..5fa77c93 100644
--- a/src/components/utils/popover.ts
+++ b/src/components/utils/popover.ts
@@ -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 {
+ /**
+ * 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 {
*/
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 {
/**
* 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 {
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 {
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 {
*/
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 {
* @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 {
* 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 {
}
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 {
* 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 {
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 {
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 {
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 {
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 {
}));
}
+ 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 {
* 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 {
],
});
}
+
+ /**
+ * 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;
+ }
}
diff --git a/src/styles/export.css b/src/styles/export.css
index 977b2e44..889f7cfd 100644
--- a/src/styles/export.css
+++ b/src/styles/export.css
@@ -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);
}
diff --git a/src/styles/popover.css b/src/styles/popover.css
index 0b477dee..ff7fae91 100644
--- a/src/styles/popover.css
+++ b/src/styles/popover.css
@@ -6,6 +6,7 @@
flex-direction: column;
padding: 6px;
min-width: 200px;
+ width: 200px;
overflow: hidden;
box-sizing: border-box;
flex-shrink: 0;
@@ -42,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;
}
@@ -64,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;
@@ -98,6 +159,12 @@
display: none;
}
}
+
+ &--confirmation, &--active, &--focused {
+ .ce-popover__item-icon {
+ box-shadow: none;
+ }
+ }
}
&__no-found {
@@ -110,10 +177,6 @@
&--shown {
display: block;
}
-
- &:hover {
- background-color: transparent;
- }
}
@media (--mobile) {
@@ -141,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;
+ }
}
diff --git a/src/styles/settings.css b/src/styles/settings.css
index 78622a0b..40b4f9d6 100644
--- a/src/styles/settings.css
+++ b/src/styles/settings.css
@@ -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)));
}
}
diff --git a/src/styles/variables.css b/src/styles/variables.css
index ca9b14b3..9012c0f9 100644
--- a/src/styles/variables.css
+++ b/src/styles/variables.css
@@ -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;
diff --git a/test/cypress/tests/api/tools.spec.ts b/test/cypress/tests/api/tools.spec.ts
index e85d7458..aa73a933 100644
--- a/test/cypress/tests/api/tools.spec.ts
+++ b/test/cypress/tests/api/tools.spec.ts
@@ -1,4 +1,7 @@
import { ToolboxConfig, BlockToolData, ToolboxConfigEntry } from '../../../../types';
+import { TunesMenuConfig } from '../../../../types/tools';
+
+/* eslint-disable @typescript-eslint/no-empty-function */
const ICON = '';
@@ -266,4 +269,225 @@ describe('Editor Tools Api', () => {
.should('not.contain', skippedEntryTitle);
});
});
-});
\ No newline at end of file
+
+ 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);
+ });
+ });
+});
diff --git a/test/cypress/tests/api/tunes.spec.ts b/test/cypress/tests/api/tunes.spec.ts
new file mode 100644
index 00000000..3c1721cb
--- /dev/null
+++ b/test/cypress/tests/api/tunes.spec.ts
@@ -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);
+ });
+});
diff --git a/test/cypress/tests/onchange.spec.ts b/test/cypress/tests/onchange.spec.ts
index f4740c30..4e275d85 100644
--- a/test/cypress/tests/onchange.spec.ts
+++ b/test/cypress/tests/onchange.spec.ts
@@ -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({
diff --git a/test/cypress/tests/utils/flipper.spec.ts b/test/cypress/tests/utils/flipper.spec.ts
new file mode 100644
index 00000000..6ca1c518
--- /dev/null
+++ b/test/cypress/tests/utils/flipper.spec.ts
@@ -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;
+ });
+});
diff --git a/test/cypress/tests/utils/popover.spec.ts b/test/cypress/tests/utils/popover.spec.ts
new file mode 100644
index 00000000..d0191da4
--- /dev/null
+++ b/test/cypress/tests/utils/popover.spec.ts
@@ -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');
+ });
+ });
+});
diff --git a/test/cypress/tsconfig.json b/test/cypress/tsconfig.json
index a996c048..4d48eb0f 100644
--- a/test/cypress/tsconfig.json
+++ b/test/cypress/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2017",
- "lib": ["dom", "es2017", "es2018"],
+ "lib": ["dom", "es2017", "es2018", "es2019"],
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
diff --git a/tsconfig.json b/tsconfig.json
index c95c063b..c4e387d9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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"],
+ "lib": ["dom", "es2017", "es2018", "es2019"],
// allows to import .json files for i18n
"resolveJsonModule": true,
diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts
index 21649db8..f684bf78 100644
--- a/types/api/blocks.d.ts
+++ b/types/api/blocks.d.ts
@@ -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
diff --git a/types/block-tunes/block-tune.d.ts b/types/block-tunes/block-tune.d.ts
index f323fd82..70169d82 100644
--- a/types/block-tunes/block-tune.d.ts
+++ b/types/block-tunes/block-tune.d.ts
@@ -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.
diff --git a/types/configs/index.d.ts b/types/configs/index.d.ts
index 20723a1d..d1d20a7e 100644
--- a/types/configs/index.d.ts
+++ b/types/configs/index.d.ts
@@ -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'
diff --git a/types/configs/popover.d.ts b/types/configs/popover.d.ts
new file mode 100644
index 00000000..5002464e
--- /dev/null
+++ b/types/configs/popover.d.ts
@@ -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;
+
+ 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
+
diff --git a/types/index.d.ts b/types/index.d.ts
index f9deaf8c..52108034 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -71,6 +71,9 @@ export {
Dictionary,
DictValue,
I18nConfig,
+ PopoverItem,
+ PopoverItemWithConfirmation,
+ PopoverItemWithoutConfirmation
} from './configs';
export {OutputData, OutputBlockData} from './data-formats/output-data';
export { BlockAPI } from './api'
diff --git a/types/tools/block-tool.d.ts b/types/tools/block-tool.d.ts
index afc2a0ed..8c9bb858 100644
--- a/types/tools/block-tool.d.ts
+++ b/types/tools/block-tool.d.ts
@@ -5,6 +5,7 @@ import { ToolConfig } from './tool-config';
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
diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts
index c6d61cdf..c9771627 100644
--- a/types/tools/tool-settings.d.ts
+++ b/types/tools/tool-settings.d.ts
@@ -1,5 +1,6 @@
import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index';
+import { PopoverItem } from '../configs';
/**
* Tool may specify its toolbox configuration
@@ -27,6 +28,12 @@ export interface ToolboxConfigEntry {
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}
*