mirror of
https://github.com/codex-team/editor.js
synced 2024-06-08 08:52:15 +02:00
Block tunes as a popover (#2091)
* Default tunes to popover * Add the rest of default tunes * Add popover * Cleanup * Rename custom content * Cleanup * Add ability to open block settings upwards * Fix tests * Cleanup default tunes * Rename and cleanup * Add ability to display rendered custom tunes * cleanup * Rename * Add flag to close tunes popover * Cleanup * i18n * Cleanup * Fix build and tests * Fix for iframe * Add comments * Display active item, move closeOnActivate to popover * Add confirmation support to popover * Handle boolean value in confirmation param * Clarify flippable logic in popover * Comments * Pass editor element as a param of popover constructor * Fix readability * Tests * Fix flipper for confirmation element * Update confirmation config structure * Rename onClick to onActivate * Fix tests and build * Make confirmation props optional * Simplify processing tunes * Renamings * Fix text block tunes * Docs * Update event type * Move enabling confirmation state to separate method * move popover types * Unhardcode color * Support toggling * Add support of disabled items * Fix tab in empty block leading to selecting second item in popover * Remove margins for styles api settings button class * Fix arrow navigation between blocks after opening block tunes * Cleaup in default tunes code * Fix chaining confirmations * Colors * Types * Change the way flippable elements of popover custom area are set * Remove borders around popover icons * Fix untabbable inline toolbar * Fix locked scroll after closing tunes popover on mobile * Cleanup * Set max popover width * Make popover icon's border outside * Fix tab issue * Fix focus/hover issue * Reformat * Cleanup * Fix opening block tunes via keyboard * Add disableSpecialHoverAndFocusBehavior * Add deprecated comment * Cleanup * Fix popover active state * Fix checklist deletion with confirmation * Fix checklist deletion 2 * Fix popover focus * Fix popover items being impossible to flip after searching * Fix popover item highlighting issue * Update flipper.spec.ts * Fixes after review * Add Tunes Api tests * Fix multiple popover entries configured by one tune * Add tool's renderSettings() tests * Add popover confirmation state test * Fix popover width on mobile * Add popover tests * Add changelog and update version * Update changelog * Fix block tunes being unable to open after tune activation Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
This commit is contained in:
parent
39c4114b97
commit
581289c03e
|
@ -1,5 +1,14 @@
|
||||||
# Changelog
|
# 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
|
### 2.25.0
|
||||||
|
|
||||||
- `New` — *Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool! <br>
|
- `New` — *Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool! <br>
|
||||||
|
|
|
@ -194,9 +194,11 @@
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"toolbox": {
|
"toolbox": {
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
"Filter": "Поиск",
|
|
||||||
"Nothing found": "Ничего не найдено"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"popover": {
|
||||||
|
"Filter": "Поиск",
|
||||||
|
"Nothing found": "Ничего не найдено"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@editorjs/editorjs",
|
"name": "@editorjs/editorjs",
|
||||||
"version": "2.25.0",
|
"version": "2.26.0",
|
||||||
"description": "Editor.js — Native JS, based on API and Open Source",
|
"description": "Editor.js — Native JS, based on API and Open Source",
|
||||||
"main": "dist/editor.js",
|
"main": "dist/editor.js",
|
||||||
"types": "./types/index.d.ts",
|
"types": "./types/index.d.ts",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
*
|
*
|
||||||
* @copyright <CodeX Team> 2018
|
* @copyright <CodeX Team> 2018
|
||||||
*/
|
*/
|
||||||
import { API, BlockTune } from '../../../types';
|
import { API, BlockTune, PopoverItem } from '../../../types';
|
||||||
import $ from '../dom';
|
import $ from '../dom';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -23,32 +23,6 @@ export default class DeleteTune implements BlockTune {
|
||||||
*/
|
*/
|
||||||
private readonly api: API;
|
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
|
* DeleteTune constructor
|
||||||
*
|
*
|
||||||
|
@ -56,30 +30,21 @@ export default class DeleteTune implements BlockTune {
|
||||||
*/
|
*/
|
||||||
constructor({ api }) {
|
constructor({ api }) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
|
|
||||||
this.resetConfirmation = (): void => {
|
|
||||||
this.setConfirmation(false);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create "Delete" button and add click event listener
|
* Tune's appearance in block settings menu
|
||||||
*
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
*/
|
||||||
public render(): HTMLElement {
|
public render(): PopoverItem {
|
||||||
this.nodes.button = $.make('div', [this.CSS.button, this.CSS.buttonDelete], {});
|
return {
|
||||||
this.nodes.button.appendChild($.svg('cross', 12, 12));
|
icon: $.svg('cross', 14, 14).outerHTML,
|
||||||
this.api.listeners.on(this.nodes.button, 'click', (event: MouseEvent) => this.handleClick(event), false);
|
label: this.api.i18n.t('Delete'),
|
||||||
|
name: 'delete',
|
||||||
/**
|
confirmation: {
|
||||||
* Enable tooltip module
|
label: this.api.i18n.t('Click to delete'),
|
||||||
*/
|
onActivate: (item, e): void => this.handleClick(e),
|
||||||
this.api.tooltip.onHover(this.nodes.button, this.api.i18n.t('Delete'), {
|
},
|
||||||
hidingDelay: 300,
|
};
|
||||||
});
|
|
||||||
|
|
||||||
return this.nodes.button;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -88,43 +53,6 @@ export default class DeleteTune implements BlockTune {
|
||||||
* @param {MouseEvent} event - click event
|
* @param {MouseEvent} event - click event
|
||||||
*/
|
*/
|
||||||
public handleClick(event: MouseEvent): void {
|
public handleClick(event: MouseEvent): void {
|
||||||
/**
|
this.api.blocks.delete();
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import $ from '../dom';
|
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
|
* Styles
|
||||||
*
|
|
||||||
* @type {{wrapper: string}}
|
|
||||||
*/
|
*/
|
||||||
private CSS = {
|
private CSS = {
|
||||||
button: 'ce-settings__button',
|
|
||||||
wrapper: 'ce-tune-move-down',
|
|
||||||
animation: 'wobble',
|
animation: 'wobble',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -45,43 +42,32 @@ export default class MoveDownTune implements BlockTune {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return 'move down' button
|
* Tune's appearance in block settings menu
|
||||||
*
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
*/
|
||||||
public render(): HTMLElement {
|
public render(): PopoverItem {
|
||||||
const moveDownButton = $.make('div', [this.CSS.button, this.CSS.wrapper], {});
|
return {
|
||||||
|
icon: $.svg('arrow-down', 14, 14).outerHTML,
|
||||||
moveDownButton.appendChild($.svg('arrow-down', 14, 14));
|
label: this.api.i18n.t('Move down'),
|
||||||
this.api.listeners.on(
|
onActivate: (item, event): void => this.handleClick(event),
|
||||||
moveDownButton,
|
name: 'move-down',
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle clicks on 'move down' button
|
* Handle clicks on 'move down' button
|
||||||
*
|
*
|
||||||
* @param {MouseEvent} event - click event
|
* @param 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 currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
||||||
const nextBlock = this.api.blocks.getBlockByIndex(currentBlockIndex + 1);
|
const nextBlock = this.api.blocks.getBlockByIndex(currentBlockIndex + 1);
|
||||||
|
|
||||||
// If Block is last do nothing
|
// If Block is last do nothing
|
||||||
if (!nextBlock) {
|
if (!nextBlock) {
|
||||||
|
const button = (event.target as HTMLElement)
|
||||||
|
.closest('.' + Popover.CSS.item)
|
||||||
|
.querySelector('.' + Popover.CSS.itemIcon);
|
||||||
|
|
||||||
button.classList.add(this.CSS.animation);
|
button.classList.add(this.CSS.animation);
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
@ -110,8 +96,5 @@ export default class MoveDownTune implements BlockTune {
|
||||||
this.api.blocks.move(currentBlockIndex + 1);
|
this.api.blocks.move(currentBlockIndex + 1);
|
||||||
|
|
||||||
this.api.toolbar.toggleBlockSettings(true);
|
this.api.toolbar.toggleBlockSettings(true);
|
||||||
|
|
||||||
/** Hide the Tooltip */
|
|
||||||
this.api.tooltip.hide();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
* @copyright <CodeX Team> 2018
|
* @copyright <CodeX Team> 2018
|
||||||
*/
|
*/
|
||||||
import $ from '../dom';
|
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
|
* Styles
|
||||||
*
|
|
||||||
* @type {{wrapper: string}}
|
|
||||||
*/
|
*/
|
||||||
private CSS = {
|
private CSS = {
|
||||||
button: 'ce-settings__button',
|
|
||||||
wrapper: 'ce-tune-move-up',
|
|
||||||
animation: 'wobble',
|
animation: 'wobble',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -44,43 +41,32 @@ export default class MoveUpTune implements BlockTune {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create "MoveUp" button and add click event listener
|
* Tune's appearance in block settings menu
|
||||||
*
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
*/
|
||||||
public render(): HTMLElement {
|
public render(): PopoverItem {
|
||||||
const moveUpButton = $.make('div', [this.CSS.button, this.CSS.wrapper], {});
|
return {
|
||||||
|
icon: $.svg('arrow-up', 14, 14).outerHTML,
|
||||||
moveUpButton.appendChild($.svg('arrow-up', 14, 14));
|
label: this.api.i18n.t('Move up'),
|
||||||
this.api.listeners.on(
|
onActivate: (item, e): void => this.handleClick(e),
|
||||||
moveUpButton,
|
name: 'move-up',
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move current block up
|
* Move current block up
|
||||||
*
|
*
|
||||||
* @param {MouseEvent} event - click event
|
* @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 currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
||||||
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
|
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
|
||||||
const previousBlock = this.api.blocks.getBlockByIndex(currentBlockIndex - 1);
|
const previousBlock = this.api.blocks.getBlockByIndex(currentBlockIndex - 1);
|
||||||
|
|
||||||
if (currentBlockIndex === 0 || !currentBlock || !previousBlock) {
|
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);
|
button.classList.add(this.CSS.animation);
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
@ -118,8 +104,5 @@ export default class MoveUpTune implements BlockTune {
|
||||||
this.api.blocks.move(currentBlockIndex - 1);
|
this.api.blocks.move(currentBlockIndex - 1);
|
||||||
|
|
||||||
this.api.toolbar.toggleBlockSettings(true);
|
this.api.toolbar.toggleBlockSettings(true);
|
||||||
|
|
||||||
/** Hide the Tooltip */
|
|
||||||
this.api.tooltip.hide();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import {
|
||||||
BlockTune as IBlockTune,
|
BlockTune as IBlockTune,
|
||||||
SanitizerConfig,
|
SanitizerConfig,
|
||||||
ToolConfig,
|
ToolConfig,
|
||||||
ToolboxConfigEntry
|
ToolboxConfigEntry,
|
||||||
|
PopoverItem
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
|
|
||||||
import { SavedData } from '../../../types/data-formats';
|
import { SavedData } from '../../../types/data-formats';
|
||||||
|
@ -642,22 +643,33 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enumerates initialized tunes and returns fragment that can be appended to the toolbars area
|
* Returns data to render in tunes menu.
|
||||||
*
|
* Splits block tunes settings into 2 groups: popover items and custom html.
|
||||||
* @returns {DocumentFragment[]}
|
|
||||||
*/
|
*/
|
||||||
public renderTunes(): [DocumentFragment, DocumentFragment] {
|
public getTunes(): [PopoverItem[], HTMLElement] {
|
||||||
const tunesElement = document.createDocumentFragment();
|
const customHtmlTunesContainer = document.createElement('div');
|
||||||
const defaultTunesElement = document.createDocumentFragment();
|
const tunesItems: PopoverItem[] = [];
|
||||||
|
|
||||||
this.tunesInstances.forEach((tune) => {
|
/** Tool's tunes: may be defined as return value of optional renderSettings method */
|
||||||
$.append(tunesElement, tune.render());
|
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
|
||||||
});
|
|
||||||
this.defaultTunesInstances.forEach((tune) => {
|
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
|
||||||
$.append(defaultTunesElement, tune.render());
|
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<BlockEvents> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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")
|
* 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)
|
* This method returns the entry that is related to the Block (depended on the Block data)
|
||||||
|
|
|
@ -60,6 +60,19 @@ export default class DomIterator {
|
||||||
return this.items[this.cursor];
|
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
|
* Sets items. Can be used when iterable items changed dynamically
|
||||||
*
|
*
|
||||||
|
|
|
@ -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
|
* Flipper is a component that iterates passed items array by TAB or Arrows and clicks it by ENTER
|
||||||
*/
|
*/
|
||||||
export default class Flipper {
|
export default class Flipper {
|
||||||
|
/**
|
||||||
|
* True if flipper is currently activated
|
||||||
|
*/
|
||||||
|
public get isActivated(): boolean {
|
||||||
|
return this.activated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instance of flipper iterator
|
* Instance of flipper iterator
|
||||||
*
|
*
|
||||||
|
@ -64,6 +71,11 @@ export default class Flipper {
|
||||||
*/
|
*/
|
||||||
private readonly activateCallback: (item: HTMLElement) => void;
|
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
|
* @param {FlipperOptions} options - different constructing settings
|
||||||
*/
|
*/
|
||||||
|
@ -93,21 +105,30 @@ export default class Flipper {
|
||||||
/**
|
/**
|
||||||
* Active tab/arrows handling by 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;
|
this.activated = true;
|
||||||
|
|
||||||
if (items) {
|
if (items) {
|
||||||
this.iterator.setItems(items);
|
this.iterator.setItems(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cursorPosition !== undefined) {
|
||||||
|
this.iterator.setCursor(cursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listening all keydowns on document and react on TAB/Enter press
|
* Listening all keydowns on document and react on TAB/Enter press
|
||||||
* TAB will leaf iterator items
|
* TAB will leaf iterator items
|
||||||
* ENTER will click the focused item
|
* 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;
|
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
|
* Drops flipper's iterator cursor
|
||||||
*
|
*
|
||||||
|
@ -258,5 +297,7 @@ export default class Flipper {
|
||||||
if (this.iterator.currentItem) {
|
if (this.iterator.currentItem) {
|
||||||
this.iterator.currentItem.scrollIntoViewIfNeeded();
|
this.iterator.currentItem.scrollIntoViewIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.flipCallbacks.forEach(cb => cb());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,12 @@
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"toolbox": {
|
"toolbox": {
|
||||||
"Add": "",
|
"Add": ""
|
||||||
"Filter": "",
|
|
||||||
"Nothing found": ""
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"popover": {
|
||||||
|
"Filter": "",
|
||||||
|
"Nothing found": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toolNames": {
|
"toolNames": {
|
||||||
|
@ -35,7 +37,8 @@
|
||||||
},
|
},
|
||||||
"blockTunes": {
|
"blockTunes": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"Delete": ""
|
"Delete": "",
|
||||||
|
"Click to delete": ""
|
||||||
},
|
},
|
||||||
"moveUp": {
|
"moveUp": {
|
||||||
"Move up": ""
|
"Move up": ""
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default class BlocksAPI extends Module {
|
||||||
delete: (index?: number): void => this.delete(index),
|
delete: (index?: number): void => this.delete(index),
|
||||||
swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex),
|
swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex),
|
||||||
move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex),
|
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),
|
getById: (id: string): BlockAPIInterface | null => this.getById(id),
|
||||||
getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(),
|
getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(),
|
||||||
getBlockIndex: (id: string): number => this.getBlockIndex(id),
|
getBlockIndex: (id: string): number => this.getBlockIndex(id),
|
||||||
|
@ -77,7 +77,7 @@ export default class BlocksAPI extends Module {
|
||||||
*
|
*
|
||||||
* @param {number} index - index to get
|
* @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);
|
const block = this.Editor.BlockManager.getBlockByIndex(index);
|
||||||
|
|
||||||
if (block === undefined) {
|
if (block === undefined) {
|
||||||
|
|
|
@ -129,13 +129,14 @@ export default class BlockEvents extends Module {
|
||||||
const canOpenToolbox = currentBlock.tool.isDefault && isEmptyBlock;
|
const canOpenToolbox = currentBlock.tool.isDefault && isEmptyBlock;
|
||||||
const conversionToolbarOpened = !isEmptyBlock && ConversionToolbar.opened;
|
const conversionToolbarOpened = !isEmptyBlock && ConversionToolbar.opened;
|
||||||
const inlineToolbarOpened = !isEmptyBlock && !SelectionUtils.isCollapsed && InlineToolbar.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
|
* For empty Blocks we show Plus button via Toolbox only for default Blocks
|
||||||
*/
|
*/
|
||||||
if (canOpenToolbox) {
|
if (canOpenToolbox) {
|
||||||
this.activateToolbox();
|
this.activateToolbox();
|
||||||
} else if (!conversionToolbarOpened && !inlineToolbarOpened) {
|
} else if (canOpenBlockTunes) {
|
||||||
this.activateBlockSettings();
|
this.activateBlockSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +1,23 @@
|
||||||
import Module from '../../__module';
|
import Module from '../../__module';
|
||||||
import $ from '../../dom';
|
import $ from '../../dom';
|
||||||
import Flipper, { FlipperOptions } from '../../flipper';
|
|
||||||
import * as _ from '../../utils';
|
import * as _ from '../../utils';
|
||||||
import SelectionUtils from '../../selection';
|
import SelectionUtils from '../../selection';
|
||||||
import Block from '../../block';
|
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
|
* HTML Elements that used for BlockSettings
|
||||||
*/
|
*/
|
||||||
interface BlockSettingsNodes {
|
interface BlockSettingsNodes {
|
||||||
wrapper: HTMLElement;
|
wrapper: HTMLElement;
|
||||||
toolSettings: HTMLElement;
|
|
||||||
defaultSettings: HTMLElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block Settings
|
* Block Settings
|
||||||
*
|
*
|
||||||
* ____ Settings Panel ____
|
|
||||||
* | ...................... |
|
|
||||||
* | . Tool Settings . |
|
|
||||||
* | ...................... |
|
|
||||||
* | . Default Settings . |
|
|
||||||
* | ...................... |
|
|
||||||
* |________________________|
|
|
||||||
*
|
|
||||||
* @todo Make Block Settings no-module but a standalone class, like Toolbox
|
* @todo Make Block Settings no-module but a standalone class, like Toolbox
|
||||||
*/
|
*/
|
||||||
export default class BlockSettings extends Module<BlockSettingsNodes> {
|
export default class BlockSettings extends Module<BlockSettingsNodes> {
|
||||||
|
@ -42,82 +35,50 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block Settings CSS
|
* Block Settings CSS
|
||||||
*
|
|
||||||
* @returns {{wrapper, wrapperOpened, toolSettings, defaultSettings, button}}
|
|
||||||
*/
|
*/
|
||||||
public get CSS(): { [name: string]: string } {
|
public get CSS(): { [name: string]: string } {
|
||||||
return {
|
return {
|
||||||
// Settings Panel
|
settings: 'ce-settings',
|
||||||
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',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is Block Settings opened or not
|
* Opened state
|
||||||
*
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
*/
|
||||||
public get opened(): boolean {
|
public opened = false;
|
||||||
return this.nodes.wrapper.classList.contains(this.CSS.wrapperOpened);
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Page selection utils
|
||||||
*/
|
*/
|
||||||
private selection: SelectionUtils = new SelectionUtils();
|
private selection: SelectionUtils = new SelectionUtils();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popover instance. There is a util for vertical lists.
|
||||||
|
*/
|
||||||
|
private popover: Popover;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Panel with block settings with 2 sections:
|
* Panel with block settings with 2 sections:
|
||||||
* - Tool's Settings
|
* - Tool's Settings
|
||||||
* - Default Settings [Move, Remove, etc]
|
* - Default Settings [Move, Remove, etc]
|
||||||
*/
|
*/
|
||||||
public make(): void {
|
public make(): void {
|
||||||
this.nodes.wrapper = $.make('div', this.CSS.wrapper);
|
this.nodes.wrapper = $.make('div');
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroys module
|
* Destroys module
|
||||||
*/
|
*/
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
/**
|
|
||||||
* Sometimes (in read-only mode) there is no Flipper
|
|
||||||
*/
|
|
||||||
if (this.flipper) {
|
|
||||||
this.flipper.deactivate();
|
|
||||||
this.flipper = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.removeAllNodes();
|
this.removeAllNodes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,7 +88,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
||||||
* @param targetBlock - near which Block we should open BlockSettings
|
* @param targetBlock - near which Block we should open BlockSettings
|
||||||
*/
|
*/
|
||||||
public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void {
|
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,
|
* 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
|
* Fill Tool's settings
|
||||||
*/
|
*/
|
||||||
this.addToolSettings(targetBlock);
|
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
|
||||||
|
|
||||||
/**
|
|
||||||
* Add default settings that presents for all Blocks
|
|
||||||
*/
|
|
||||||
this.addTunes(targetBlock);
|
|
||||||
|
|
||||||
/** Tell to subscribers that block settings is opened */
|
/** Tell to subscribers that block settings is opened */
|
||||||
this.eventsDispatcher.emit(this.events.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
|
* Close Block Settings pane
|
||||||
*/
|
*/
|
||||||
public close(): void {
|
public close(): void {
|
||||||
this.nodes.wrapper.classList.remove(this.CSS.wrapperOpened);
|
this.opened = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If selection is at editor on Block Settings closing,
|
* 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;
|
this.Editor.BlockManager.currentBlock.selected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear settings */
|
|
||||||
this.nodes.toolSettings.innerHTML = '';
|
|
||||||
this.nodes.defaultSettings.innerHTML = '';
|
|
||||||
|
|
||||||
/** Tell to subscribers that block settings is closed */
|
/** Tell to subscribers that block settings is closed */
|
||||||
this.eventsDispatcher.emit(this.events.closed);
|
this.eventsDispatcher.emit(this.events.closed);
|
||||||
|
|
||||||
/** Clear cached buttons */
|
if (this.popover) {
|
||||||
this.buttons = [];
|
this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked);
|
||||||
|
this.popover.destroy();
|
||||||
/** Clear focus on active button */
|
this.popover.getElement().remove();
|
||||||
this.flipper.deactivate();
|
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;
|
const { StylesAPI } = this.Editor;
|
||||||
|
/** Query buttons and inputs inside tunes html */
|
||||||
/**
|
const controls = container.querySelectorAll<HTMLElement>(
|
||||||
* Return from cache
|
|
||||||
* if exists
|
|
||||||
*/
|
|
||||||
if (this.buttons.length !== 0) {
|
|
||||||
return this.buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolSettings = this.nodes.toolSettings.querySelectorAll(
|
|
||||||
// Select buttons and inputs
|
|
||||||
`.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}`
|
`.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}`
|
||||||
);
|
);
|
||||||
const defaultSettings = this.nodes.defaultSettings.querySelectorAll(`.${this.CSS.button}`);
|
|
||||||
|
|
||||||
toolSettings.forEach((item) => {
|
return Array.from(controls);
|
||||||
this.buttons.push((item as HTMLElement));
|
|
||||||
});
|
|
||||||
|
|
||||||
defaultSettings.forEach((item) => {
|
|
||||||
this.buttons.push((item as HTMLElement));
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.buttons;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Tool's settings
|
* Handles overlay click
|
||||||
*
|
|
||||||
* @param targetBlock - Block to render settings
|
|
||||||
*/
|
*/
|
||||||
private addToolSettings(targetBlock): void {
|
private onOverlayClicked = (): void => {
|
||||||
const settingsElement = targetBlock.renderSettings();
|
this.close();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -388,7 +388,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
||||||
* Appending Toolbar components to itself
|
* Appending Toolbar components to itself
|
||||||
*/
|
*/
|
||||||
$.append(this.nodes.actions, this.makeToolbox());
|
$.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
|
* Append toolbar to the Editor
|
||||||
|
@ -407,8 +407,8 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
||||||
api: this.Editor.API.methods,
|
api: this.Editor.API.methods,
|
||||||
tools: this.Editor.Tools.blockTools,
|
tools: this.Editor.Tools.blockTools,
|
||||||
i18nLabels: {
|
i18nLabels: {
|
||||||
filter: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Filter'),
|
filter: I18n.ui(I18nInternalNS.ui.popover, 'Filter'),
|
||||||
nothingFound: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Nothing found'),
|
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,9 @@ import { BlockToolAPI } from '../block';
|
||||||
import Shortcuts from '../utils/shortcuts';
|
import Shortcuts from '../utils/shortcuts';
|
||||||
import BlockTool from '../tools/block';
|
import BlockTool from '../tools/block';
|
||||||
import ToolsCollection from '../tools/collection';
|
import ToolsCollection from '../tools/collection';
|
||||||
import { API, BlockToolData, ToolboxConfigEntry } from '../../../types';
|
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem } from '../../../types';
|
||||||
import EventsDispatcher from '../utils/events';
|
import EventsDispatcher from '../utils/events';
|
||||||
import Popover, { PopoverEvent, PopoverItem } from '../utils/popover';
|
import Popover, { PopoverEvent } from '../utils/popover';
|
||||||
import I18n from '../i18n';
|
import I18n from '../i18n';
|
||||||
import { I18nInternalNS } from '../i18n/namespace-internal';
|
import { I18nInternalNS } from '../i18n/namespace-internal';
|
||||||
|
|
||||||
|
@ -99,7 +99,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
||||||
private static get CSS(): { [name: string]: string } {
|
private static get CSS(): { [name: string]: string } {
|
||||||
return {
|
return {
|
||||||
toolbox: 'ce-toolbox',
|
toolbox: 'ce-toolbox',
|
||||||
toolboxOpenedTop: 'ce-toolbox--opened-top',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,6 +127,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
||||||
*/
|
*/
|
||||||
public make(): Element {
|
public make(): Element {
|
||||||
this.popover = new Popover({
|
this.popover = new Popover({
|
||||||
|
scopeElement: this.api.ui.nodes.redactor,
|
||||||
className: Toolbox.CSS.toolbox,
|
className: Toolbox.CSS.toolbox,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
filterLabel: this.i18nLabels.filter,
|
filterLabel: this.i18nLabels.filter,
|
||||||
|
@ -189,15 +189,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
||||||
return;
|
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.popover.show();
|
||||||
this.opened = true;
|
this.opened = true;
|
||||||
this.emit(ToolboxEvent.Opened);
|
this.emit(ToolboxEvent.Opened);
|
||||||
|
@ -209,7 +200,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
||||||
public close(): void {
|
public close(): void {
|
||||||
this.popover.hide();
|
this.popover.hide();
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop);
|
|
||||||
this.emit(ToolboxEvent.Closed);
|
this.emit(ToolboxEvent.Closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,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
|
* Handles overlay click
|
||||||
*/
|
*/
|
||||||
|
@ -284,7 +259,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
||||||
icon: toolboxItem.icon,
|
icon: toolboxItem.icon,
|
||||||
label: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
|
label: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
onClick: (e): void => {
|
onActivate: (e): void => {
|
||||||
this.toolButtonActivated(tool.name, toolboxItem.data);
|
this.toolButtonActivated(tool.name, toolboxItem.data);
|
||||||
},
|
},
|
||||||
secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
|
secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
|
||||||
|
|
|
@ -5,39 +5,7 @@ import SearchInput from './search-input';
|
||||||
import EventsDispatcher from './events';
|
import EventsDispatcher from './events';
|
||||||
import { isMobileScreen, keyCodes, cacheable } from '../utils';
|
import { isMobileScreen, keyCodes, cacheable } from '../utils';
|
||||||
import ScrollLocker from './scroll-locker';
|
import ScrollLocker from './scroll-locker';
|
||||||
|
import { PopoverItem, PopoverItemWithConfirmation } from '../../../types';
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event that can be triggered by the Popover
|
* Event that can be triggered by the Popover
|
||||||
|
@ -47,17 +15,37 @@ export enum PopoverEvent {
|
||||||
* When popover overlay is clicked
|
* When popover overlay is clicked
|
||||||
*/
|
*/
|
||||||
OverlayClicked = 'overlay-clicked',
|
OverlayClicked = 'overlay-clicked',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When popover closes
|
||||||
|
*/
|
||||||
|
Close = 'close'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Popover is the UI element for displaying vertical lists
|
* Popover is the UI element for displaying vertical lists
|
||||||
*/
|
*/
|
||||||
export default class Popover extends EventsDispatcher<PopoverEvent> {
|
export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
|
/**
|
||||||
|
* Flipper - module for keyboard iteration between elements
|
||||||
|
*/
|
||||||
|
public flipper: Flipper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Items list to be displayed
|
* Items list to be displayed
|
||||||
*/
|
*/
|
||||||
private readonly items: PopoverItem[];
|
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.
|
* Stores the visibility state.
|
||||||
*/
|
*/
|
||||||
|
@ -90,11 +78,6 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
*/
|
*/
|
||||||
private listeners: Listeners;
|
private listeners: Listeners;
|
||||||
|
|
||||||
/**
|
|
||||||
* Flipper - module for keyboard iteration between elements
|
|
||||||
*/
|
|
||||||
private flipper: Flipper;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pass true to enable local search field
|
* Pass true to enable local search field
|
||||||
*/
|
*/
|
||||||
|
@ -118,20 +101,27 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
/**
|
/**
|
||||||
* Style classes
|
* Style classes
|
||||||
*/
|
*/
|
||||||
private static get CSS(): {
|
public static get CSS(): {
|
||||||
popover: string;
|
popover: string;
|
||||||
popoverOpened: string;
|
popoverOpened: string;
|
||||||
itemsWrapper: string;
|
itemsWrapper: string;
|
||||||
item: string;
|
item: string;
|
||||||
itemHidden: string;
|
itemHidden: string;
|
||||||
itemFocused: string;
|
itemFocused: string;
|
||||||
|
itemActive: string;
|
||||||
|
itemDisabled: string;
|
||||||
itemLabel: string;
|
itemLabel: string;
|
||||||
itemIcon: string;
|
itemIcon: string;
|
||||||
itemSecondaryLabel: string;
|
itemSecondaryLabel: string;
|
||||||
|
itemConfirmation: string;
|
||||||
|
itemNoHover: string;
|
||||||
|
itemNoFocus: string;
|
||||||
noFoundMessage: string;
|
noFoundMessage: string;
|
||||||
noFoundMessageShown: string;
|
noFoundMessageShown: string;
|
||||||
popoverOverlay: string;
|
popoverOverlay: string;
|
||||||
popoverOverlayHidden: string;
|
popoverOverlayHidden: string;
|
||||||
|
customContent: string;
|
||||||
|
customContentHidden: string;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
popover: 'ce-popover',
|
popover: 'ce-popover',
|
||||||
|
@ -140,6 +130,11 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
item: 'ce-popover__item',
|
item: 'ce-popover__item',
|
||||||
itemHidden: 'ce-popover__item--hidden',
|
itemHidden: 'ce-popover__item--hidden',
|
||||||
itemFocused: 'ce-popover__item--focused',
|
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',
|
itemLabel: 'ce-popover__item-label',
|
||||||
itemIcon: 'ce-popover__item-icon',
|
itemIcon: 'ce-popover__item-icon',
|
||||||
itemSecondaryLabel: 'ce-popover__item-secondary-label',
|
itemSecondaryLabel: 'ce-popover__item-secondary-label',
|
||||||
|
@ -147,6 +142,8 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
noFoundMessageShown: 'ce-popover__no-found--shown',
|
noFoundMessageShown: 'ce-popover__no-found--shown',
|
||||||
popoverOverlay: 'ce-popover__overlay',
|
popoverOverlay: 'ce-popover__overlay',
|
||||||
popoverOverlayHidden: 'ce-popover__overlay--hidden',
|
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()
|
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
|
* 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.className - additional class name to be added to the popover wrapper
|
||||||
* @param options.filterLabel - label for the search Field
|
* @param options.filterLabel - label for the search Field
|
||||||
* @param options.nothingFoundLabel - label of the 'nothing found' message
|
* @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[];
|
items: PopoverItem[];
|
||||||
className?: string;
|
className?: string;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
filterLabel: string;
|
filterLabel: string;
|
||||||
nothingFoundLabel: string;
|
nothingFoundLabel: string;
|
||||||
|
customContent?: HTMLElement;
|
||||||
|
customContentFlippableItems?: HTMLElement[];
|
||||||
|
scopeElement: HTMLElement;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.items = items;
|
this.items = items;
|
||||||
|
this.customContent = customContent;
|
||||||
|
this.customContentFlippableItems = customContentFlippableItems;
|
||||||
this.className = className || '';
|
this.className = className || '';
|
||||||
this.searchable = searchable;
|
this.searchable = searchable;
|
||||||
this.listeners = new Listeners();
|
this.listeners = new Listeners();
|
||||||
|
this.scopeElement = scopeElement;
|
||||||
|
|
||||||
this.filterLabel = filterLabel;
|
this.filterLabel = filterLabel;
|
||||||
this.nothingFoundLabel = nothingFoundLabel;
|
this.nothingFoundLabel = nothingFoundLabel;
|
||||||
|
@ -195,20 +211,32 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
* Shows the Popover
|
* Shows the Popover
|
||||||
*/
|
*/
|
||||||
public show(): void {
|
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
|
* Clear search and items scrolling
|
||||||
*/
|
*/
|
||||||
this.search.clear();
|
if (this.search) {
|
||||||
|
this.search.clear();
|
||||||
|
}
|
||||||
|
|
||||||
this.nodes.items.scrollTop = 0;
|
this.nodes.items.scrollTop = 0;
|
||||||
|
|
||||||
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
|
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
|
||||||
this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden);
|
this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden);
|
||||||
this.flipper.activate();
|
this.flipper.activate(this.flippableElements);
|
||||||
|
|
||||||
if (this.searchable) {
|
if (this.searchable) {
|
||||||
window.requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
this.search.focus();
|
this.search.focus();
|
||||||
});
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobileScreen()) {
|
if (isMobileScreen()) {
|
||||||
|
@ -239,13 +267,31 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isShown = false;
|
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
|
* Clears memory
|
||||||
*/
|
*/
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
|
this.flipper.deactivate();
|
||||||
this.listeners.removeAll();
|
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.
|
* Renders invisible clone of popover to get actual height.
|
||||||
*/
|
*/
|
||||||
@cacheable
|
@cacheable
|
||||||
public calculateHeight(): number {
|
private calculateHeight(): number {
|
||||||
let height = 0;
|
let height = 0;
|
||||||
const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement;
|
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);
|
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.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper);
|
||||||
this.items.forEach(item => {
|
this.items.forEach(item => {
|
||||||
this.nodes.items.appendChild(this.createItem(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.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;
|
const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement;
|
||||||
|
|
||||||
if (clickedItem) {
|
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,
|
items: this.items,
|
||||||
placeholder: this.filterLabel,
|
placeholder: this.filterLabel,
|
||||||
onSearch: (filteredItems): void => {
|
onSearch: (filteredItems): void => {
|
||||||
const itemsVisible = [];
|
const searchResultElements = [];
|
||||||
|
|
||||||
this.items.forEach((item, index) => {
|
this.items.forEach((item, index) => {
|
||||||
const itemElement = this.nodes.items.children[index];
|
const itemElement = this.nodes.items.children[index];
|
||||||
|
|
||||||
if (filteredItems.includes(item)) {
|
if (filteredItems.includes(item)) {
|
||||||
itemsVisible.push(itemElement);
|
searchResultElements.push(itemElement);
|
||||||
itemElement.classList.remove(Popover.CSS.itemHidden);
|
itemElement.classList.remove(Popover.CSS.itemHidden);
|
||||||
} else {
|
} else {
|
||||||
itemElement.classList.add(Popover.CSS.itemHidden);
|
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();
|
const allItemsDisplayed = filteredItems.length === this.items.length;
|
||||||
this.flipper.activate(itemsVisible);
|
|
||||||
this.flipper.focusFirst();
|
/**
|
||||||
|
* 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 {
|
private createItem(item: PopoverItem): HTMLElement {
|
||||||
const el = Dom.make('div', Popover.CSS.item);
|
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, {
|
const label = Dom.make('div', Popover.CSS.itemLabel, {
|
||||||
innerHTML: item.label,
|
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;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -388,23 +465,182 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
* Item click handler
|
* Item click handler
|
||||||
*
|
*
|
||||||
* @param itemEl - clicked item
|
* @param itemEl - clicked item
|
||||||
|
* @param event - click event
|
||||||
*/
|
*/
|
||||||
private itemClicked(itemEl: HTMLElement): void {
|
private itemClicked(itemEl: HTMLElement, event: PointerEvent): void {
|
||||||
const allItems = this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`);
|
const allItems = Array.from(this.nodes.items.children);
|
||||||
const itemIndex = Array.from(allItems).indexOf(itemEl);
|
const itemIndex = allItems.indexOf(itemEl);
|
||||||
const clickedItem = this.items[itemIndex];
|
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
|
* Creates Flipper instance to be able to leaf tools
|
||||||
*/
|
*/
|
||||||
private enableFlipper(): void {
|
private enableFlipper(): void {
|
||||||
const tools = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[];
|
|
||||||
|
|
||||||
this.flipper = new Flipper({
|
this.flipper = new Flipper({
|
||||||
items: tools,
|
items: this.flippableElements,
|
||||||
focusedItemClass: Popover.CSS.itemFocused,
|
focusedItemClass: Popover.CSS.itemFocused,
|
||||||
allowedKeys: [
|
allowedKeys: [
|
||||||
keyCodes.TAB,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,18 +36,11 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings
|
* Settings
|
||||||
|
* @deprecated - use tunes config instead of creating html element with controls
|
||||||
*/
|
*/
|
||||||
.cdx-settings-button {
|
.cdx-settings-button {
|
||||||
@apply --toolbar-button;
|
@apply --toolbar-button;
|
||||||
|
|
||||||
&:not(:nth-child(3n+3)) {
|
|
||||||
margin-right: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(n+4) {
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
color: var(--color-active-icon);
|
color: var(--color-active-icon);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
width: 200px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -42,12 +43,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (--mobile) {
|
@media (--mobile) {
|
||||||
|
--offset: 5px;
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
min-width: auto;
|
min-width: calc(100% - var(--offset) * 2);
|
||||||
left: 5px;
|
left: var(--offset);
|
||||||
right: 5px;
|
right: var(--offset);
|
||||||
bottom: calc(5px + env(safe-area-inset-bottom));
|
bottom: calc(var(--offset) + env(safe-area-inset-bottom));
|
||||||
top: auto;
|
top: auto;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
@ -64,19 +67,77 @@
|
||||||
&__item {
|
&__item {
|
||||||
@apply --popover-button;
|
@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 {
|
&--focused {
|
||||||
@apply --button-focused;
|
&:not(.ce-popover__item--no-visible-focus) {
|
||||||
|
@apply --button-focused;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
display: none;
|
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 {
|
&-icon {
|
||||||
@apply --tool-icon;
|
@apply --tool-icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-label {
|
&-label {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
width: 25px;
|
width: 25px;
|
||||||
|
@ -98,6 +159,12 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--confirmation, &--active, &--focused {
|
||||||
|
.ce-popover__item-icon {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__no-found {
|
&__no-found {
|
||||||
|
@ -110,10 +177,6 @@
|
||||||
&--shown {
|
&--shown {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (--mobile) {
|
@media (--mobile) {
|
||||||
|
@ -141,4 +204,17 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__custom-content:not(:empty) {
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
@media (--not-mobile) {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__custom-content--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,43 +1,15 @@
|
||||||
.ce-settings {
|
.ce-settings {
|
||||||
@apply --overlay-pane;
|
position: absolute;
|
||||||
top: var(--toolbar-buttons-size);
|
z-index: 2;
|
||||||
left: 0;
|
--gap: 8px;
|
||||||
min-width: 114px;
|
|
||||||
box-sizing: content-box;
|
|
||||||
|
|
||||||
@media (--mobile){
|
@media (--not-mobile){
|
||||||
bottom: 40px;
|
position: absolute;
|
||||||
right: auto;
|
top: calc(var(--toolbox-buttons-size) + var(--gap));
|
||||||
top: auto;
|
left: 0;
|
||||||
}
|
|
||||||
|
|
||||||
&::before{
|
&--opened-top {
|
||||||
left: auto;
|
top: calc(-1 * (var(--gap) + var(--popover-height)));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gray border, loaders
|
* Gray border, loaders
|
||||||
|
* @deprecated — use --color-line-gray instead
|
||||||
*/
|
*/
|
||||||
--color-gray-border: rgba(201, 201, 204, 0.48);
|
--color-gray-border: rgba(201, 201, 204, 0.48);
|
||||||
|
|
||||||
|
@ -69,6 +70,9 @@
|
||||||
* Confirm deletion bg
|
* Confirm deletion bg
|
||||||
*/
|
*/
|
||||||
--color-confirm: #E24A4A;
|
--color-confirm: #E24A4A;
|
||||||
|
--color-confirm-hover: #CE4343;
|
||||||
|
|
||||||
|
--color-line-gray: #EFF0F1;
|
||||||
|
|
||||||
--overlay-pane: {
|
--overlay-pane: {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -104,6 +108,17 @@
|
||||||
background: rgba(34, 186, 255, 0.08) !important;
|
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
|
* Styles for Toolbox Buttons and Plus Button
|
||||||
*/
|
*/
|
||||||
|
@ -197,12 +212,6 @@
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (--can-hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (--mobile) {
|
@media (--mobile) {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
@ -216,12 +225,12 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: var(--toolbox-buttons-size);
|
width: var(--toolbox-buttons-size);
|
||||||
height: 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;
|
border-radius: 5px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-sizing: border-box;
|
box-sizing: content-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import { ToolboxConfig, BlockToolData, ToolboxConfigEntry } from '../../../../types';
|
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>';
|
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>';
|
||||||
|
|
||||||
|
@ -266,4 +269,225 @@ describe('Editor Tools Api', () => {
|
||||||
.should('not.contain', skippedEntryTitle);
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
136
test/cypress/tests/api/tunes.spec.ts
Normal file
136
test/cypress/tests/api/tunes.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -262,8 +262,12 @@ describe('onChange callback', () => {
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
.get('div.ce-settings__button--delete')
|
.get('div[data-item-name=delete]')
|
||||||
.click()
|
.click();
|
||||||
|
|
||||||
|
/** Second click for confirmation */
|
||||||
|
cy.get('[data-cy=editorjs]')
|
||||||
|
.get('div[data-item-name=delete]')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
|
@ -292,7 +296,7 @@ describe('onChange callback', () => {
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
.get('div.ce-tune-move-up')
|
.get('div[data-item-name=move-up]')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
|
|
97
test/cypress/tests/utils/flipper.spec.ts
Normal file
97
test/cypress/tests/utils/flipper.spec.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
188
test/cypress/tests/utils/popover.spec.ts
Normal file
188
test/cypress/tests/utils/popover.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"target": "es2017",
|
||||||
"lib": ["dom", "es2017", "es2018"],
|
"lib": ["dom", "es2017", "es2018", "es2019"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"target": "es2017",
|
"target": "es2017",
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"moduleResolution": "node", // This resolution strategy attempts to mimic the Node.js module resolution mechanism at runtime
|
"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
|
// allows to import .json files for i18n
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
|
2
types/api/blocks.d.ts
vendored
2
types/api/blocks.d.ts
vendored
|
@ -52,7 +52,7 @@ export interface Blocks {
|
||||||
* Returns Block API object by passed Block index
|
* Returns Block API object by passed Block index
|
||||||
* @param {number} index
|
* @param {number} index
|
||||||
*/
|
*/
|
||||||
getBlockByIndex(index: number): BlockAPI | void;
|
getBlockByIndex(index: number): BlockAPI | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns Block API object by passed Block id
|
* Returns Block API object by passed Block id
|
||||||
|
|
5
types/block-tunes/block-tune.d.ts
vendored
5
types/block-tunes/block-tune.d.ts
vendored
|
@ -1,5 +1,6 @@
|
||||||
import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index';
|
import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index';
|
||||||
import { BlockTuneData } from './block-tune-data';
|
import { BlockTuneData } from './block-tune-data';
|
||||||
|
import { TunesMenuConfig } from '../tools';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes BLockTune blueprint
|
* Describes BLockTune blueprint
|
||||||
|
@ -7,10 +8,8 @@ import { BlockTuneData } from './block-tune-data';
|
||||||
export interface BlockTune {
|
export interface BlockTune {
|
||||||
/**
|
/**
|
||||||
* Returns block tune HTMLElement
|
* Returns block tune HTMLElement
|
||||||
*
|
|
||||||
* @return {HTMLElement}
|
|
||||||
*/
|
*/
|
||||||
render(): HTMLElement;
|
render(): HTMLElement | TunesMenuConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method called on Tool render. Pass Tool content as an argument.
|
* Method called on Tool render. Pass Tool content as an argument.
|
||||||
|
|
3
types/configs/index.d.ts
vendored
3
types/configs/index.d.ts
vendored
|
@ -1,3 +1,5 @@
|
||||||
|
import { fromCallback } from 'cypress/types/bluebird';
|
||||||
|
|
||||||
export * from './editor-config';
|
export * from './editor-config';
|
||||||
export * from './sanitizer-config';
|
export * from './sanitizer-config';
|
||||||
export * from './paste-config';
|
export * from './paste-config';
|
||||||
|
@ -5,3 +7,4 @@ export * from './conversion-config';
|
||||||
export * from './log-levels';
|
export * from './log-levels';
|
||||||
export * from './i18n-config';
|
export * from './i18n-config';
|
||||||
export * from './i18n-dictionary';
|
export * from './i18n-dictionary';
|
||||||
|
export * from './popover'
|
||||||
|
|
79
types/configs/popover.d.ts
vendored
Normal file
79
types/configs/popover.d.ts
vendored
Normal 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
|
||||||
|
|
3
types/index.d.ts
vendored
3
types/index.d.ts
vendored
|
@ -71,6 +71,9 @@ export {
|
||||||
Dictionary,
|
Dictionary,
|
||||||
DictValue,
|
DictValue,
|
||||||
I18nConfig,
|
I18nConfig,
|
||||||
|
PopoverItem,
|
||||||
|
PopoverItemWithConfirmation,
|
||||||
|
PopoverItemWithoutConfirmation
|
||||||
} from './configs';
|
} from './configs';
|
||||||
export {OutputData, OutputBlockData} from './data-formats/output-data';
|
export {OutputData, OutputBlockData} from './data-formats/output-data';
|
||||||
export { BlockAPI } from './api'
|
export { BlockAPI } from './api'
|
||||||
|
|
4
types/tools/block-tool.d.ts
vendored
4
types/tools/block-tool.d.ts
vendored
|
@ -5,6 +5,7 @@ import { ToolConfig } from './tool-config';
|
||||||
import { API, BlockAPI, ToolboxConfig } from '../index';
|
import { API, BlockAPI, ToolboxConfig } from '../index';
|
||||||
import { PasteEvent } from './paste-events';
|
import { PasteEvent } from './paste-events';
|
||||||
import { MoveEvent } from './hook-events';
|
import { MoveEvent } from './hook-events';
|
||||||
|
import { TunesMenuConfig } from './tool-settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describe Block Tool object
|
* Describe Block Tool object
|
||||||
|
@ -25,9 +26,8 @@ export interface BlockTool extends BaseTool {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Block's settings block
|
* Create Block's settings block
|
||||||
* @return {HTMLElement}
|
|
||||||
*/
|
*/
|
||||||
renderSettings?(): HTMLElement;
|
renderSettings?(): HTMLElement | TunesMenuConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Block's data
|
* Validate Block's data
|
||||||
|
|
7
types/tools/tool-settings.d.ts
vendored
7
types/tools/tool-settings.d.ts
vendored
|
@ -1,5 +1,6 @@
|
||||||
import { ToolConfig } from './tool-config';
|
import { ToolConfig } from './tool-config';
|
||||||
import { ToolConstructable, BlockToolData } from './index';
|
import { ToolConstructable, BlockToolData } from './index';
|
||||||
|
import { PopoverItem } from '../configs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool may specify its toolbox configuration
|
* Tool may specify its toolbox configuration
|
||||||
|
@ -27,6 +28,12 @@ export interface ToolboxConfigEntry {
|
||||||
data?: BlockToolData
|
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}
|
* Object passed to the Tool's constructor by {@link EditorConfig#tools}
|
||||||
*
|
*
|
||||||
|
|
Loading…
Reference in a new issue