diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5d37082a..4b45ece3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog + +### 2.26.0 + +- `New` — *UI* — Meet the new Block Tunes look! Vertical menu with simple JSON configuration (and support for legacy way of defining block tunes). +- `New` — *Block Tunes API* — Now `render()` method of a Block Tune can return `TunesMenuConfig` besides `HTMLElement`. This impovement is a key to the new straightforward way of configuring tune's appearance in Block Tunes menu. +- `New` — *Tools API* — As well as `render()` in `Tunes Api`, Tool's `renderSettings()` now also supports `TunesMenuConfig` return value format. +- `Deprecated` — *Styles API* — CSS classes `.cdx-settings-button` and `.cdx-settings-button--active` are not recommended to use. Consider configuring your block settings with new JSON API instead. +- `Fix` — Prevent flipper from handling the event which caused it's instantiating. + ### 2.25.0 - `New` — *Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool!
diff --git a/example/example-i18n.html b/example/example-i18n.html index c123bd7b..930dbaef 100644 --- a/example/example-i18n.html +++ b/example/example-i18n.html @@ -194,9 +194,11 @@ "toolbar": { "toolbox": { "Add": "Добавить", - "Filter": "Поиск", - "Nothing found": "Ничего не найдено" } + }, + "popover": { + "Filter": "Поиск", + "Nothing found": "Ничего не найдено" } }, diff --git a/package.json b/package.json index ddc46344..8478ab8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.25.0", + "version": "2.26.0", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editor.js", "types": "./types/index.d.ts", diff --git a/src/components/block-tunes/block-tune-delete.ts b/src/components/block-tunes/block-tune-delete.ts index a9b4c08a..27bd612d 100644 --- a/src/components/block-tunes/block-tune-delete.ts +++ b/src/components/block-tunes/block-tune-delete.ts @@ -4,7 +4,7 @@ * * @copyright 2018 */ -import { API, BlockTune } from '../../../types'; +import { API, BlockTune, PopoverItem } from '../../../types'; import $ from '../dom'; /** @@ -23,32 +23,6 @@ export default class DeleteTune implements BlockTune { */ private readonly api: API; - /** - * Styles - */ - private CSS = { - button: 'ce-settings__button', - buttonDelete: 'ce-settings__button--delete', - buttonConfirm: 'ce-settings__button--confirm', - }; - - /** - * Delete confirmation - */ - private needConfirmation: boolean; - - /** - * set false confirmation state - */ - private readonly resetConfirmation: () => void; - - /** - * Tune nodes - */ - private nodes: {button: HTMLElement} = { - button: null, - }; - /** * DeleteTune constructor * @@ -56,30 +30,21 @@ export default class DeleteTune implements BlockTune { */ constructor({ api }) { this.api = api; - - this.resetConfirmation = (): void => { - this.setConfirmation(false); - }; } /** - * Create "Delete" button and add click event listener - * - * @returns {HTMLElement} + * Tune's appearance in block settings menu */ - public render(): HTMLElement { - this.nodes.button = $.make('div', [this.CSS.button, this.CSS.buttonDelete], {}); - this.nodes.button.appendChild($.svg('cross', 12, 12)); - this.api.listeners.on(this.nodes.button, 'click', (event: MouseEvent) => this.handleClick(event), false); - - /** - * Enable tooltip module - */ - this.api.tooltip.onHover(this.nodes.button, this.api.i18n.t('Delete'), { - hidingDelay: 300, - }); - - return this.nodes.button; + public render(): PopoverItem { + return { + icon: $.svg('cross', 14, 14).outerHTML, + label: this.api.i18n.t('Delete'), + name: 'delete', + confirmation: { + label: this.api.i18n.t('Click to delete'), + onActivate: (item, e): void => this.handleClick(e), + }, + }; } /** @@ -88,43 +53,6 @@ export default class DeleteTune implements BlockTune { * @param {MouseEvent} event - click event */ public handleClick(event: MouseEvent): void { - /** - * if block is not waiting the confirmation, subscribe on block-settings-closing event to reset - * otherwise delete block - */ - if (!this.needConfirmation) { - this.setConfirmation(true); - - /** - * Subscribe on event. - * When toolbar block settings is closed but block deletion is not confirmed, - * then reset confirmation state - */ - this.api.events.on('block-settings-closed', this.resetConfirmation); - } else { - /** - * Unsubscribe from block-settings closing event - */ - this.api.events.off('block-settings-closed', this.resetConfirmation); - - this.api.blocks.delete(); - this.api.toolbar.close(); - this.api.tooltip.hide(); - - /** - * Prevent firing ui~documentClicked that can drop currentBlock pointer - */ - event.stopPropagation(); - } - } - - /** - * change tune state - * - * @param {boolean} state - delete confirmation state - */ - private setConfirmation(state: boolean): void { - this.needConfirmation = state; - this.nodes.button.classList.add(this.CSS.buttonConfirm); + this.api.blocks.delete(); } } diff --git a/src/components/block-tunes/block-tune-move-down.ts b/src/components/block-tunes/block-tune-move-down.ts index 2eadbf8e..614a778f 100644 --- a/src/components/block-tunes/block-tune-move-down.ts +++ b/src/components/block-tunes/block-tune-move-down.ts @@ -6,7 +6,8 @@ */ import $ from '../dom'; -import { API, BlockTune } from '../../../types'; +import { API, BlockTune, PopoverItem } from '../../../types'; +import Popover from '../utils/popover'; /** * @@ -26,12 +27,8 @@ export default class MoveDownTune implements BlockTune { /** * Styles - * - * @type {{wrapper: string}} */ private CSS = { - button: 'ce-settings__button', - wrapper: 'ce-tune-move-down', animation: 'wobble', }; @@ -45,43 +42,32 @@ export default class MoveDownTune implements BlockTune { } /** - * Return 'move down' button - * - * @returns {HTMLElement} + * Tune's appearance in block settings menu */ - public render(): HTMLElement { - const moveDownButton = $.make('div', [this.CSS.button, this.CSS.wrapper], {}); - - moveDownButton.appendChild($.svg('arrow-down', 14, 14)); - this.api.listeners.on( - moveDownButton, - 'click', - (event) => this.handleClick(event as MouseEvent, moveDownButton), - false - ); - - /** - * Enable tooltip module on button - */ - this.api.tooltip.onHover(moveDownButton, this.api.i18n.t('Move down'), { - hidingDelay: 300, - }); - - return moveDownButton; + public render(): PopoverItem { + return { + icon: $.svg('arrow-down', 14, 14).outerHTML, + label: this.api.i18n.t('Move down'), + onActivate: (item, event): void => this.handleClick(event), + name: 'move-down', + }; } /** * Handle clicks on 'move down' button * - * @param {MouseEvent} event - click event - * @param {HTMLElement} button - clicked button + * @param event - click event */ - public handleClick(event: MouseEvent, button: HTMLElement): void { + public handleClick(event: MouseEvent): void { const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); const nextBlock = this.api.blocks.getBlockByIndex(currentBlockIndex + 1); // If Block is last do nothing if (!nextBlock) { + const button = (event.target as HTMLElement) + .closest('.' + Popover.CSS.item) + .querySelector('.' + Popover.CSS.itemIcon); + button.classList.add(this.CSS.animation); window.setTimeout(() => { @@ -110,8 +96,5 @@ export default class MoveDownTune implements BlockTune { this.api.blocks.move(currentBlockIndex + 1); this.api.toolbar.toggleBlockSettings(true); - - /** Hide the Tooltip */ - this.api.tooltip.hide(); } } diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts index b29ba6c3..1d07e3bf 100644 --- a/src/components/block-tunes/block-tune-move-up.ts +++ b/src/components/block-tunes/block-tune-move-up.ts @@ -5,7 +5,8 @@ * @copyright 2018 */ import $ from '../dom'; -import { API, BlockTune } from '../../../types'; +import { API, BlockTune, BlockAPI, PopoverItem } from '../../../types'; +import Popover from '../../components/utils/popover'; /** * @@ -25,12 +26,8 @@ export default class MoveUpTune implements BlockTune { /** * Styles - * - * @type {{wrapper: string}} */ private CSS = { - button: 'ce-settings__button', - wrapper: 'ce-tune-move-up', animation: 'wobble', }; @@ -44,43 +41,32 @@ export default class MoveUpTune implements BlockTune { } /** - * Create "MoveUp" button and add click event listener - * - * @returns {HTMLElement} + * Tune's appearance in block settings menu */ - public render(): HTMLElement { - const moveUpButton = $.make('div', [this.CSS.button, this.CSS.wrapper], {}); - - moveUpButton.appendChild($.svg('arrow-up', 14, 14)); - this.api.listeners.on( - moveUpButton, - 'click', - (event) => this.handleClick(event as MouseEvent, moveUpButton), - false - ); - - /** - * Enable tooltip module on button - */ - this.api.tooltip.onHover(moveUpButton, this.api.i18n.t('Move up'), { - hidingDelay: 300, - }); - - return moveUpButton; + public render(): PopoverItem { + return { + icon: $.svg('arrow-up', 14, 14).outerHTML, + label: this.api.i18n.t('Move up'), + onActivate: (item, e): void => this.handleClick(e), + name: 'move-up', + }; } /** * Move current block up * * @param {MouseEvent} event - click event - * @param {HTMLElement} button - clicked button */ - public handleClick(event: MouseEvent, button: HTMLElement): void { + public handleClick(event: MouseEvent): void { const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex); const previousBlock = this.api.blocks.getBlockByIndex(currentBlockIndex - 1); if (currentBlockIndex === 0 || !currentBlock || !previousBlock) { + const button = (event.target as HTMLElement) + .closest('.' + Popover.CSS.item) + .querySelector('.' + Popover.CSS.itemIcon); + button.classList.add(this.CSS.animation); window.setTimeout(() => { @@ -118,8 +104,5 @@ export default class MoveUpTune implements BlockTune { this.api.blocks.move(currentBlockIndex - 1); this.api.toolbar.toggleBlockSettings(true); - - /** Hide the Tooltip */ - this.api.tooltip.hide(); } } diff --git a/src/components/block/index.ts b/src/components/block/index.ts index a9306c66..d2007f6a 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -5,7 +5,8 @@ import { BlockTune as IBlockTune, SanitizerConfig, ToolConfig, - ToolboxConfigEntry + ToolboxConfigEntry, + PopoverItem } from '../../../types'; import { SavedData } from '../../../types/data-formats'; @@ -642,22 +643,33 @@ export default class Block extends EventsDispatcher { } /** - * Enumerates initialized tunes and returns fragment that can be appended to the toolbars area - * - * @returns {DocumentFragment[]} + * Returns data to render in tunes menu. + * Splits block tunes settings into 2 groups: popover items and custom html. */ - public renderTunes(): [DocumentFragment, DocumentFragment] { - const tunesElement = document.createDocumentFragment(); - const defaultTunesElement = document.createDocumentFragment(); + public getTunes(): [PopoverItem[], HTMLElement] { + const customHtmlTunesContainer = document.createElement('div'); + const tunesItems: PopoverItem[] = []; - this.tunesInstances.forEach((tune) => { - $.append(tunesElement, tune.render()); - }); - this.defaultTunesInstances.forEach((tune) => { - $.append(defaultTunesElement, tune.render()); + /** Tool's tunes: may be defined as return value of optional renderSettings method */ + const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : []; + + /** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */ + const commonTunes = [ + ...this.defaultTunesInstances.values(), + ...this.tunesInstances.values(), + ].map(tuneInstance => tuneInstance.render()); + + [tunesDefinedInTool, commonTunes].flat().forEach(rendered => { + if ($.isElement(rendered)) { + customHtmlTunesContainer.appendChild(rendered); + } else if (Array.isArray(rendered)) { + tunesItems.push(...rendered); + } else { + tunesItems.push(rendered); + } }); - return [tunesElement, defaultTunesElement]; + return [tunesItems, customHtmlTunesContainer]; } /** @@ -726,15 +738,6 @@ export default class Block extends EventsDispatcher { } } - /** - * Call Tool instance renderSettings method - */ - public renderSettings(): HTMLElement | undefined { - if (_.isFunction(this.toolInstance.renderSettings)) { - return this.toolInstance.renderSettings(); - } - } - /** * Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3") * This method returns the entry that is related to the Block (depended on the Block data) diff --git a/src/components/domIterator.ts b/src/components/domIterator.ts index 339e70d5..d11d3b4d 100644 --- a/src/components/domIterator.ts +++ b/src/components/domIterator.ts @@ -60,6 +60,19 @@ export default class DomIterator { return this.items[this.cursor]; } + /** + * Sets cursor to specified position + * + * @param cursorPosition - new cursor position + */ + public setCursor(cursorPosition: number): void { + if (cursorPosition < this.items.length && cursorPosition >= -1) { + this.dropCursor(); + this.cursor = cursorPosition; + this.items[this.cursor].classList.add(this.focusedCssClass); + } + } + /** * Sets items. Can be used when iterable items changed dynamically * diff --git a/src/components/flipper.ts b/src/components/flipper.ts index 7ab00fbb..ed2df15f 100644 --- a/src/components/flipper.ts +++ b/src/components/flipper.ts @@ -40,6 +40,13 @@ export interface FlipperOptions { * Flipper is a component that iterates passed items array by TAB or Arrows and clicks it by ENTER */ export default class Flipper { + /** + * True if flipper is currently activated + */ + public get isActivated(): boolean { + return this.activated; + } + /** * Instance of flipper iterator * @@ -64,6 +71,11 @@ export default class Flipper { */ private readonly activateCallback: (item: HTMLElement) => void; + /** + * Contains list of callbacks to be executed on each flip + */ + private flipCallbacks: Array<() => void> = [] + /** * @param {FlipperOptions} options - different constructing settings */ @@ -93,21 +105,30 @@ export default class Flipper { /** * Active tab/arrows handling by flipper * - * @param {HTMLElement[]} items - Some modules (like, InlineToolbar, BlockSettings) might refresh buttons dynamically + * @param items - Some modules (like, InlineToolbar, BlockSettings) might refresh buttons dynamically + * @param cursorPosition - index of the item that should be focused once flipper is activated */ - public activate(items?: HTMLElement[]): void { + public activate(items?: HTMLElement[], cursorPosition?: number): void { this.activated = true; if (items) { this.iterator.setItems(items); } + if (cursorPosition !== undefined) { + this.iterator.setCursor(cursorPosition); + } + /** * Listening all keydowns on document and react on TAB/Enter press * TAB will leaf iterator items * ENTER will click the focused item + * + * Note: the event should be handled in capturing mode on following reasons: + * - prevents plugins inner keydown handlers from being called while keyboard navigation + * - otherwise this handler will be called at the moment it is attached which causes false flipper firing (see https://techread.me/js-addeventlistener-fires-for-past-events/) */ - document.addEventListener('keydown', this.onKeyDown); + document.addEventListener('keydown', this.onKeyDown, true); } /** @@ -151,6 +172,24 @@ export default class Flipper { return !!this.iterator.currentItem; } + /** + * Registeres function that should be executed on each navigation action + * + * @param cb - function to execute + */ + public onFlip(cb: () => void): void { + this.flipCallbacks.push(cb); + } + + /** + * Unregisteres function that is executed on each navigation action + * + * @param cb - function to stop executing + */ + public removeOnFlip(cb: () => void): void { + this.flipCallbacks = this.flipCallbacks.filter(fn => fn !== cb); + } + /** * Drops flipper's iterator cursor * @@ -258,5 +297,7 @@ export default class Flipper { if (this.iterator.currentItem) { this.iterator.currentItem.scrollIntoViewIfNeeded(); } + + this.flipCallbacks.forEach(cb => cb()); } } diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json index d44df52c..32761be6 100644 --- a/src/components/i18n/locales/en/messages.json +++ b/src/components/i18n/locales/en/messages.json @@ -13,10 +13,12 @@ }, "toolbar": { "toolbox": { - "Add": "", - "Filter": "", - "Nothing found": "" + "Add": "" } + }, + "popover": { + "Filter": "", + "Nothing found": "" } }, "toolNames": { @@ -35,7 +37,8 @@ }, "blockTunes": { "delete": { - "Delete": "" + "Delete": "", + "Click to delete": "" }, "moveUp": { "Move up": "" diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 8d742c85..57e7b6d5 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -23,7 +23,7 @@ export default class BlocksAPI extends Module { delete: (index?: number): void => this.delete(index), swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex), move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex), - getBlockByIndex: (index: number): BlockAPIInterface | void => this.getBlockByIndex(index), + getBlockByIndex: (index: number): BlockAPIInterface | undefined => this.getBlockByIndex(index), getById: (id: string): BlockAPIInterface | null => this.getById(id), getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(), getBlockIndex: (id: string): number => this.getBlockIndex(id), @@ -77,7 +77,7 @@ export default class BlocksAPI extends Module { * * @param {number} index - index to get */ - public getBlockByIndex(index: number): BlockAPIInterface | void { + public getBlockByIndex(index: number): BlockAPIInterface | undefined { const block = this.Editor.BlockManager.getBlockByIndex(index); if (block === undefined) { diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 7fece2f4..7aa62018 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -129,13 +129,14 @@ export default class BlockEvents extends Module { const canOpenToolbox = currentBlock.tool.isDefault && isEmptyBlock; const conversionToolbarOpened = !isEmptyBlock && ConversionToolbar.opened; const inlineToolbarOpened = !isEmptyBlock && !SelectionUtils.isCollapsed && InlineToolbar.opened; + const canOpenBlockTunes = !conversionToolbarOpened && !inlineToolbarOpened; /** * For empty Blocks we show Plus button via Toolbox only for default Blocks */ if (canOpenToolbox) { this.activateToolbox(); - } else if (!conversionToolbarOpened && !inlineToolbarOpened) { + } else if (canOpenBlockTunes) { this.activateBlockSettings(); } } diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 30f84633..16f72e36 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -1,30 +1,23 @@ import Module from '../../__module'; import $ from '../../dom'; -import Flipper, { FlipperOptions } from '../../flipper'; import * as _ from '../../utils'; import SelectionUtils from '../../selection'; import Block from '../../block'; +import Popover, { PopoverEvent } from '../../utils/popover'; +import I18n from '../../i18n'; +import { I18nInternalNS } from '../../i18n/namespace-internal'; +import Flipper from '../../flipper'; /** * HTML Elements that used for BlockSettings */ interface BlockSettingsNodes { wrapper: HTMLElement; - toolSettings: HTMLElement; - defaultSettings: HTMLElement; } /** * Block Settings * - * ____ Settings Panel ____ - * | ...................... | - * | . Tool Settings . | - * | ...................... | - * | . Default Settings . | - * | ...................... | - * |________________________| - * * @todo Make Block Settings no-module but a standalone class, like Toolbox */ export default class BlockSettings extends Module { @@ -42,82 +35,50 @@ export default class BlockSettings extends Module { /** * Block Settings CSS - * - * @returns {{wrapper, wrapperOpened, toolSettings, defaultSettings, button}} */ public get CSS(): { [name: string]: string } { return { - // Settings Panel - wrapper: 'ce-settings', - wrapperOpened: 'ce-settings--opened', - toolSettings: 'ce-settings__plugin-zone', - defaultSettings: 'ce-settings__default-zone', - - button: 'ce-settings__button', - - focusedButton: 'ce-settings__button--focused', - focusedButtonAnimated: 'ce-settings__button--focused-animated', + settings: 'ce-settings', }; } /** - * Is Block Settings opened or not - * - * @returns {boolean} + * Opened state */ - public get opened(): boolean { - return this.nodes.wrapper.classList.contains(this.CSS.wrapperOpened); + public opened = false; + + /** + * Getter for inner popover's flipper instance + * + * @todo remove once BlockSettings becomes standalone non-module class + */ + public get flipper(): Flipper { + return this.popover.flipper; } - /** - * List of buttons - */ - private buttons: HTMLElement[] = []; - - /** - * Instance of class that responses for leafing buttons by arrows/tab - * - * @type {Flipper|null} - */ - private flipper: Flipper = null; - /** * Page selection utils */ private selection: SelectionUtils = new SelectionUtils(); + /** + * Popover instance. There is a util for vertical lists. + */ + private popover: Popover; + /** * Panel with block settings with 2 sections: * - Tool's Settings * - Default Settings [Move, Remove, etc] */ public make(): void { - this.nodes.wrapper = $.make('div', this.CSS.wrapper); - - this.nodes.toolSettings = $.make('div', this.CSS.toolSettings); - this.nodes.defaultSettings = $.make('div', this.CSS.defaultSettings); - - $.append(this.nodes.wrapper, [this.nodes.toolSettings, this.nodes.defaultSettings]); - - /** - * Active leafing by arrows/tab - * Buttons will be filled on opening - */ - this.enableFlipper(); + this.nodes.wrapper = $.make('div'); } /** * Destroys module */ public destroy(): void { - /** - * Sometimes (in read-only mode) there is no Flipper - */ - if (this.flipper) { - this.flipper.deactivate(); - this.flipper = null; - } - this.removeAllNodes(); } @@ -127,7 +88,7 @@ export default class BlockSettings extends Module { * @param targetBlock - near which Block we should open BlockSettings */ public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void { - this.nodes.wrapper.classList.add(this.CSS.wrapperOpened); + this.opened = true; /** * If block settings contains any inputs, focus will be set there, @@ -144,24 +105,41 @@ export default class BlockSettings extends Module { /** * Fill Tool's settings */ - this.addToolSettings(targetBlock); - - /** - * Add default settings that presents for all Blocks - */ - this.addTunes(targetBlock); + const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes(); /** Tell to subscribers that block settings is opened */ this.eventsDispatcher.emit(this.events.opened); - this.flipper.activate(this.blockTunesButtons); + this.popover = new Popover({ + className: this.CSS.settings, + searchable: true, + filterLabel: I18n.ui(I18nInternalNS.ui.popover, 'Filter'), + nothingFoundLabel: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), + items: tunesItems, + customContent: customHtmlTunesContainer, + customContentFlippableItems: this.getControls(customHtmlTunesContainer), + scopeElement: this.Editor.API.methods.ui.nodes.redactor, + }); + this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked); + this.popover.on(PopoverEvent.Close, () => this.close()); + + this.nodes.wrapper.append(this.popover.getElement()); + + this.popover.show(); + } + + /** + * Returns root block settings element + */ + public getElement(): HTMLElement { + return this.nodes.wrapper; } /** * Close Block Settings pane */ public close(): void { - this.nodes.wrapper.classList.remove(this.CSS.wrapperOpened); + this.opened = false; /** * If selection is at editor on Block Settings closing, @@ -183,106 +161,36 @@ export default class BlockSettings extends Module { this.Editor.BlockManager.currentBlock.selected = false; } - /** Clear settings */ - this.nodes.toolSettings.innerHTML = ''; - this.nodes.defaultSettings.innerHTML = ''; - /** Tell to subscribers that block settings is closed */ this.eventsDispatcher.emit(this.events.closed); - /** Clear cached buttons */ - this.buttons = []; - - /** Clear focus on active button */ - this.flipper.deactivate(); + if (this.popover) { + this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked); + this.popover.destroy(); + this.popover.getElement().remove(); + this.popover = null; + } } /** - * Returns Tools Settings and Default Settings + * Returns list of buttons and inputs inside specified container * - * @returns {HTMLElement[]} + * @param container - container to query controls inside of */ - public get blockTunesButtons(): HTMLElement[] { + private getControls(container: HTMLElement): HTMLElement[] { const { StylesAPI } = this.Editor; - - /** - * Return from cache - * if exists - */ - if (this.buttons.length !== 0) { - return this.buttons; - } - - const toolSettings = this.nodes.toolSettings.querySelectorAll( - // Select buttons and inputs + /** Query buttons and inputs inside tunes html */ + const controls = container.querySelectorAll( `.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}` ); - const defaultSettings = this.nodes.defaultSettings.querySelectorAll(`.${this.CSS.button}`); - toolSettings.forEach((item) => { - this.buttons.push((item as HTMLElement)); - }); - - defaultSettings.forEach((item) => { - this.buttons.push((item as HTMLElement)); - }); - - return this.buttons; + return Array.from(controls); } /** - * Add Tool's settings - * - * @param targetBlock - Block to render settings + * Handles overlay click */ - private addToolSettings(targetBlock): void { - const settingsElement = targetBlock.renderSettings(); - - if (settingsElement) { - $.append(this.nodes.toolSettings, settingsElement); - } - } - - /** - * Add tunes: provided by user and default ones - * - * @param targetBlock - Block to render its Tunes set - */ - private addTunes(targetBlock): void { - const [toolTunes, defaultTunes] = targetBlock.renderTunes(); - - $.append(this.nodes.toolSettings, toolTunes); - $.append(this.nodes.defaultSettings, defaultTunes); - } - - /** - * Active leafing by arrows/tab - * Buttons will be filled on opening - */ - private enableFlipper(): void { - this.flipper = new Flipper({ - focusedItemClass: this.CSS.focusedButton, - /** - * @param {HTMLElement} focusedItem - activated Tune - */ - activateCallback: (focusedItem) => { - /** - * If focused item is editable element, close block settings - */ - if (focusedItem && $.canSetCaret(focusedItem)) { - this.close(); - - return; - } - - /** - * Restoring focus on current Block after settings clicked. - * For example, when H3 changed to H2 — DOM Elements replaced, so we need to focus a new one - */ - _.delay(() => { - this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock); - }, 50)(); - }, - } as FlipperOptions); + private onOverlayClicked = (): void => { + this.close(); } } diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 9a041766..5c469fa0 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -388,7 +388,7 @@ export default class Toolbar extends Module { * Appending Toolbar components to itself */ $.append(this.nodes.actions, this.makeToolbox()); - $.append(this.nodes.actions, this.Editor.BlockSettings.nodes.wrapper); + $.append(this.nodes.actions, this.Editor.BlockSettings.getElement()); /** * Append toolbar to the Editor @@ -407,8 +407,8 @@ export default class Toolbar extends Module { api: this.Editor.API.methods, tools: this.Editor.Tools.blockTools, i18nLabels: { - filter: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Filter'), - nothingFound: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Nothing found'), + filter: I18n.ui(I18nInternalNS.ui.popover, 'Filter'), + nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), }, }); diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 7ae48c58..e213ac9a 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -3,9 +3,9 @@ import { BlockToolAPI } from '../block'; import Shortcuts from '../utils/shortcuts'; import BlockTool from '../tools/block'; import ToolsCollection from '../tools/collection'; -import { API, BlockToolData, ToolboxConfigEntry } from '../../../types'; +import { API, BlockToolData, ToolboxConfigEntry, PopoverItem } from '../../../types'; import EventsDispatcher from '../utils/events'; -import Popover, { PopoverEvent, PopoverItem } from '../utils/popover'; +import Popover, { PopoverEvent } from '../utils/popover'; import I18n from '../i18n'; import { I18nInternalNS } from '../i18n/namespace-internal'; @@ -99,7 +99,6 @@ export default class Toolbox extends EventsDispatcher { private static get CSS(): { [name: string]: string } { return { toolbox: 'ce-toolbox', - toolboxOpenedTop: 'ce-toolbox--opened-top', }; } @@ -128,6 +127,7 @@ export default class Toolbox extends EventsDispatcher { */ public make(): Element { this.popover = new Popover({ + scopeElement: this.api.ui.nodes.redactor, className: Toolbox.CSS.toolbox, searchable: true, filterLabel: this.i18nLabels.filter, @@ -189,15 +189,6 @@ export default class Toolbox extends EventsDispatcher { return; } - /** - * Open the popover above the button - * if there is not enough available space below it - */ - if (!this.shouldOpenPopoverBottom) { - this.nodes.toolbox.style.setProperty('--popover-height', this.popover.calculateHeight() + 'px'); - this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpenedTop); - } - this.popover.show(); this.opened = true; this.emit(ToolboxEvent.Opened); @@ -209,7 +200,6 @@ export default class Toolbox extends EventsDispatcher { public close(): void { this.popover.hide(); this.opened = false; - this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop); this.emit(ToolboxEvent.Closed); } @@ -224,21 +214,6 @@ export default class Toolbox extends EventsDispatcher { } } - /** - * Checks if there popover should be opened downwards. - * It happens in case there is enough space below or not enough space above - */ - private get shouldOpenPopoverBottom(): boolean { - const toolboxRect = this.nodes.toolbox.getBoundingClientRect(); - const editorElementRect = this.api.ui.nodes.redactor.getBoundingClientRect(); - const popoverHeight = this.popover.calculateHeight(); - const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight; - const popoverPotentialTopEdge = toolboxRect.top - popoverHeight; - const bottomEdgeForComparison = Math.min(window.innerHeight, editorElementRect.bottom); - - return popoverPotentialTopEdge < editorElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison; - } - /** * Handles overlay click */ @@ -284,7 +259,7 @@ export default class Toolbox extends EventsDispatcher { icon: toolboxItem.icon, label: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)), name: tool.name, - onClick: (e): void => { + onActivate: (e): void => { this.toolButtonActivated(tool.name, toolboxItem.data); }, secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '', diff --git a/src/components/utils/popover.ts b/src/components/utils/popover.ts index 0a2c30c7..5fa77c93 100644 --- a/src/components/utils/popover.ts +++ b/src/components/utils/popover.ts @@ -5,39 +5,7 @@ import SearchInput from './search-input'; import EventsDispatcher from './events'; import { isMobileScreen, keyCodes, cacheable } from '../utils'; import ScrollLocker from './scroll-locker'; - -/** - * Describe parameters for rendering the single item of Popover - */ -export interface PopoverItem { - /** - * Item icon to be appeared near a title - */ - icon: string; - - /** - * Displayed text - */ - label: string; - - /** - * Item name - * Used in data attributes needed for cypress tests - */ - name?: string; - - /** - * Additional displayed text - */ - secondaryLabel?: string; - - /** - * Itm click handler - * - * @param item - clicked item - */ - onClick: (item: PopoverItem) => void; -} +import { PopoverItem, PopoverItemWithConfirmation } from '../../../types'; /** * Event that can be triggered by the Popover @@ -47,17 +15,37 @@ export enum PopoverEvent { * When popover overlay is clicked */ OverlayClicked = 'overlay-clicked', + + /** + * When popover closes + */ + Close = 'close' } /** * Popover is the UI element for displaying vertical lists */ export default class Popover extends EventsDispatcher { + /** + * Flipper - module for keyboard iteration between elements + */ + public flipper: Flipper; + /** * Items list to be displayed */ private readonly items: PopoverItem[]; + /** + * Arbitrary html element to be inserted before items list + */ + private readonly customContent: HTMLElement; + + /** + * List of html elements inside custom content area that should be available for keyboard navigation + */ + private readonly customContentFlippableItems: HTMLElement[] = []; + /** * Stores the visibility state. */ @@ -90,11 +78,6 @@ export default class Popover extends EventsDispatcher { */ private listeners: Listeners; - /** - * Flipper - module for keyboard iteration between elements - */ - private flipper: Flipper; - /** * Pass true to enable local search field */ @@ -118,20 +101,27 @@ export default class Popover extends EventsDispatcher { /** * Style classes */ - private static get CSS(): { + public static get CSS(): { popover: string; popoverOpened: string; itemsWrapper: string; item: string; itemHidden: string; itemFocused: string; + itemActive: string; + itemDisabled: string; itemLabel: string; itemIcon: string; itemSecondaryLabel: string; + itemConfirmation: string; + itemNoHover: string; + itemNoFocus: string; noFoundMessage: string; noFoundMessageShown: string; popoverOverlay: string; popoverOverlayHidden: string; + customContent: string; + customContentHidden: string; } { return { popover: 'ce-popover', @@ -140,6 +130,11 @@ export default class Popover extends EventsDispatcher { item: 'ce-popover__item', itemHidden: 'ce-popover__item--hidden', itemFocused: 'ce-popover__item--focused', + itemActive: 'ce-popover__item--active', + itemDisabled: 'ce-popover__item--disabled', + itemConfirmation: 'ce-popover__item--confirmation', + itemNoHover: 'ce-popover__item--no-visible-hover', + itemNoFocus: 'ce-popover__item--no-visible-focus', itemLabel: 'ce-popover__item-label', itemIcon: 'ce-popover__item-icon', itemSecondaryLabel: 'ce-popover__item-secondary-label', @@ -147,6 +142,8 @@ export default class Popover extends EventsDispatcher { noFoundMessageShown: 'ce-popover__no-found--shown', popoverOverlay: 'ce-popover__overlay', popoverOverlayHidden: 'ce-popover__overlay--hidden', + customContent: 'ce-popover__custom-content', + customContentHidden: 'ce-popover__custom-content--hidden', }; } @@ -155,6 +152,16 @@ export default class Popover extends EventsDispatcher { */ private scrollLocker = new ScrollLocker() + /** + * Editor container element + */ + private scopeElement: HTMLElement; + + /** + * Stores data on popover items that are in confirmation state + */ + private itemsRequiringConfirmation: { [itemIndex: number]: PopoverItem } = {}; + /** * Creates the Popover * @@ -163,19 +170,28 @@ export default class Popover extends EventsDispatcher { * @param options.className - additional class name to be added to the popover wrapper * @param options.filterLabel - label for the search Field * @param options.nothingFoundLabel - label of the 'nothing found' message + * @param options.customContent - arbitrary html element to be inserted before items list + * @param options.customContentFlippableItems - list of html elements inside custom content area that should be available for keyboard navigation + * @param options.scopeElement - editor container element */ - constructor({ items, className, searchable, filterLabel, nothingFoundLabel }: { + constructor({ items, className, searchable, filterLabel, nothingFoundLabel, customContent, customContentFlippableItems, scopeElement }: { items: PopoverItem[]; className?: string; searchable?: boolean; filterLabel: string; nothingFoundLabel: string; + customContent?: HTMLElement; + customContentFlippableItems?: HTMLElement[]; + scopeElement: HTMLElement; }) { super(); this.items = items; + this.customContent = customContent; + this.customContentFlippableItems = customContentFlippableItems; this.className = className || ''; this.searchable = searchable; this.listeners = new Listeners(); + this.scopeElement = scopeElement; this.filterLabel = filterLabel; this.nothingFoundLabel = nothingFoundLabel; @@ -195,20 +211,32 @@ export default class Popover extends EventsDispatcher { * Shows the Popover */ public show(): void { + /** + * Open the popover above the button + * if there is not enough available space below it + */ + if (!this.shouldOpenPopoverBottom) { + this.nodes.wrapper.style.setProperty('--popover-height', this.calculateHeight() + 'px'); + this.nodes.wrapper.classList.add(this.className + '--opened-top'); + } + /** * Clear search and items scrolling */ - this.search.clear(); + if (this.search) { + this.search.clear(); + } + this.nodes.items.scrollTop = 0; this.nodes.popover.classList.add(Popover.CSS.popoverOpened); this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden); - this.flipper.activate(); + this.flipper.activate(this.flippableElements); if (this.searchable) { - window.requestAnimationFrame(() => { + setTimeout(() => { this.search.focus(); - }); + }, 100); } if (isMobileScreen()) { @@ -239,13 +267,31 @@ export default class Popover extends EventsDispatcher { } this.isShown = false; + this.nodes.wrapper.classList.remove(this.className + '--opened-top'); + + /** + * Remove confirmation state from items + */ + const confirmationStateItems = Array.from(this.nodes.items.querySelectorAll(`.${Popover.CSS.itemConfirmation}`)); + + confirmationStateItems.forEach((itemEl: HTMLElement) => this.cleanUpConfirmationStateForItem(itemEl)); + + this.disableSpecialHoverAndFocusBehavior(); + + this.emit(PopoverEvent.Close); } /** * Clears memory */ public destroy(): void { + this.flipper.deactivate(); this.listeners.removeAll(); + this.disableSpecialHoverAndFocusBehavior(); + + if (isMobileScreen()) { + this.scrollLocker.unlock(); + } } /** @@ -260,7 +306,7 @@ export default class Popover extends EventsDispatcher { * Renders invisible clone of popover to get actual height. */ @cacheable - public calculateHeight(): number { + private calculateHeight(): number { let height = 0; const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement; @@ -290,6 +336,11 @@ export default class Popover extends EventsDispatcher { this.addSearch(this.nodes.popover); } + if (this.customContent) { + this.customContent.classList.add(Popover.CSS.customContent); + this.nodes.popover.appendChild(this.customContent); + } + this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper); this.items.forEach(item => { this.nodes.items.appendChild(this.createItem(item)); @@ -302,11 +353,11 @@ export default class Popover extends EventsDispatcher { this.nodes.popover.appendChild(this.nodes.nothingFound); - this.listeners.on(this.nodes.popover, 'click', (event: KeyboardEvent|MouseEvent) => { + this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => { const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement; if (clickedItem) { - this.itemClicked(clickedItem); + this.itemClicked(clickedItem, event as PointerEvent); } }); @@ -325,27 +376,43 @@ export default class Popover extends EventsDispatcher { items: this.items, placeholder: this.filterLabel, onSearch: (filteredItems): void => { - const itemsVisible = []; + const searchResultElements = []; this.items.forEach((item, index) => { const itemElement = this.nodes.items.children[index]; if (filteredItems.includes(item)) { - itemsVisible.push(itemElement); + searchResultElements.push(itemElement); itemElement.classList.remove(Popover.CSS.itemHidden); } else { itemElement.classList.add(Popover.CSS.itemHidden); } }); - this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, itemsVisible.length === 0); + this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, searchResultElements.length === 0); /** - * Update flipper items with only visible + * In order to make keyboard navigation work correctly, flipper should be reactivated with only visible items. + * As custom html content is not displayed while search, it should be excluded from keyboard navigation. */ - this.flipper.deactivate(); - this.flipper.activate(itemsVisible); - this.flipper.focusFirst(); + const allItemsDisplayed = filteredItems.length === this.items.length; + + /** + * Contains list of elements available for keyboard navigation considering search query applied + */ + const flippableElements = allItemsDisplayed ? this.flippableElements : searchResultElements; + + if (this.customContent) { + this.customContent.classList.toggle(Popover.CSS.customContentHidden, !allItemsDisplayed); + } + + if (this.flipper.isActivated) { + /** + * Update flipper items with only visible + */ + this.reactivateFlipper(flippableElements); + this.flipper.focusFirst(); + } }, }); @@ -362,7 +429,9 @@ export default class Popover extends EventsDispatcher { private createItem(item: PopoverItem): HTMLElement { const el = Dom.make('div', Popover.CSS.item); - el.dataset.itemName = item.name; + if (item.name) { + el.dataset.itemName = item.name; + } const label = Dom.make('div', Popover.CSS.itemLabel, { innerHTML: item.label, }); @@ -381,6 +450,14 @@ export default class Popover extends EventsDispatcher { })); } + if (item.isActive) { + el.classList.add(Popover.CSS.itemActive); + } + + if (item.isDisabled) { + el.classList.add(Popover.CSS.itemDisabled); + } + return el; } @@ -388,23 +465,182 @@ export default class Popover extends EventsDispatcher { * Item click handler * * @param itemEl - clicked item + * @param event - click event */ - private itemClicked(itemEl: HTMLElement): void { - const allItems = this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`); - const itemIndex = Array.from(allItems).indexOf(itemEl); + private itemClicked(itemEl: HTMLElement, event: PointerEvent): void { + const allItems = Array.from(this.nodes.items.children); + const itemIndex = allItems.indexOf(itemEl); const clickedItem = this.items[itemIndex]; - clickedItem.onClick(clickedItem); + if (clickedItem.isDisabled) { + return; + } + + /** + * If there is any other item in confirmation state except the clicked one, clean it up + */ + allItems + .filter(item => item !== itemEl) + .forEach(item => { + this.cleanUpConfirmationStateForItem(item); + }); + + if (clickedItem.confirmation) { + this.enableConfirmationStateForItem(clickedItem as PopoverItemWithConfirmation, itemEl, itemIndex); + + return; + } + clickedItem.onActivate(clickedItem, event); + + if (clickedItem.toggle) { + clickedItem.isActive = !clickedItem.isActive; + itemEl.classList.toggle(Popover.CSS.itemActive); + } + + if (clickedItem.closeOnActivate) { + this.hide(); + } + } + + /** + * Enables confirmation state for specified item. + * Replaces item element in popover so that is becomes highlighted in a special way + * + * @param item - item to enable confirmation state for + * @param itemEl - html element corresponding to the item + * @param itemIndex - index of the item in all items list + */ + private enableConfirmationStateForItem(item: PopoverItemWithConfirmation, itemEl: HTMLElement, itemIndex: number): void { + /** Save root item requiring confirmation to restore original state on popover hide */ + if (this.itemsRequiringConfirmation[itemIndex] === undefined) { + this.itemsRequiringConfirmation[itemIndex] = item; + } + const newItemData = { + ...item, + ...item.confirmation, + confirmation: item.confirmation.confirmation, + } as PopoverItem; + + this.items[itemIndex] = newItemData; + + const confirmationStateItemEl = this.createItem(newItemData as PopoverItem); + + confirmationStateItemEl.classList.add(Popover.CSS.itemConfirmation, ...Array.from(itemEl.classList)); + itemEl.parentElement.replaceChild(confirmationStateItemEl, itemEl); + + this.enableSpecialHoverAndFocusBehavior(confirmationStateItemEl); + + this.reactivateFlipper( + this.flippableElements, + this.flippableElements.indexOf(confirmationStateItemEl) + ); + } + + /** + * Brings specified element corresponding to popover item to its original state + * + * @param itemEl - item in confirmation state + */ + private cleanUpConfirmationStateForItem(itemEl: Element): void { + const allItems = Array.from(this.nodes.items.children); + const index = allItems.indexOf(itemEl); + + const originalItem = this.itemsRequiringConfirmation[index]; + + if (originalItem === undefined) { + return; + } + const originalStateItemEl = this.createItem(originalItem); + + itemEl.parentElement.replaceChild(originalStateItemEl, itemEl); + this.items[index] = originalItem; + + delete this.itemsRequiringConfirmation[index]; + + itemEl.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); + this.disableSpecialHoverAndFocusBehavior(); + + this.reactivateFlipper( + this.flippableElements, + this.flippableElements.indexOf(originalStateItemEl) + ); + } + + /** + * Enables special focus and hover behavior for item in confirmation state. + * This is needed to prevent item from being highlighted as hovered/focused just after click. + * + * @param item - html element of the item to enable special behavior for + */ + private enableSpecialHoverAndFocusBehavior(item: HTMLElement): void { + item.classList.add(Popover.CSS.itemNoHover); + item.classList.add(Popover.CSS.itemNoFocus); + + item.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); + this.flipper.onFlip(this.onFlip); + } + + /** + * Disables special focus and hover behavior. + */ + private disableSpecialHoverAndFocusBehavior(): void { + this.removeSpecialFocusBehavior(); + this.removeSpecialHoverBehavior(); + + this.flipper.removeOnFlip(this.onFlip); + } + + /** + * Removes class responsible for special hover behavior on an item + */ + private removeSpecialHoverBehavior = (): void => { + const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoHover}`); + + if (!el) { + return; + } + + el.classList.remove(Popover.CSS.itemNoHover); + } + + /** + * Removes class responsible for special focus behavior on an item + */ + private removeSpecialFocusBehavior(): void { + const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoFocus}`); + + if (!el) { + return; + } + + el.classList.remove(Popover.CSS.itemNoFocus); + } + + /** + * Called on flipper navigation + */ + private onFlip = (): void => { + this.disableSpecialHoverAndFocusBehavior(); + } + + /** + * Reactivates flipper instance. + * Should be used if popover items html elements get replaced to preserve workability of keyboard navigation + * + * @param items - html elements to navigate through + * @param focusedIndex - index of element to be focused + */ + private reactivateFlipper(items: HTMLElement[], focusedIndex?: number): void { + this.flipper.deactivate(); + this.flipper.activate(items, focusedIndex); } /** * Creates Flipper instance to be able to leaf tools */ private enableFlipper(): void { - const tools = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[]; - this.flipper = new Flipper({ - items: tools, + items: this.flippableElements, focusedItemClass: Popover.CSS.itemFocused, allowedKeys: [ keyCodes.TAB, @@ -414,4 +650,37 @@ export default class Popover extends EventsDispatcher { ], }); } + + /** + * Returns list of elements available for keyboard navigation. + * Contains both usual popover items elements and custom html content. + */ + private get flippableElements(): HTMLElement[] { + /** + * Select html elements of popover items + */ + const popoverItemsElements = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[]; + + const customContentControlsElements = this.customContentFlippableItems || []; + + /** + * Combine elements inside custom content area with popover items elements + */ + return customContentControlsElements.concat(popoverItemsElements); + } + + /** + * Checks if popover should be opened bottom. + * It should happen when there is enough space below or not enough space above + */ + private get shouldOpenPopoverBottom(): boolean { + const toolboxRect = this.nodes.wrapper.getBoundingClientRect(); + const scopeElementRect = this.scopeElement.getBoundingClientRect(); + const popoverHeight = this.calculateHeight(); + const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight; + const popoverPotentialTopEdge = toolboxRect.top - popoverHeight; + const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom); + + return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison; + } } diff --git a/src/styles/export.css b/src/styles/export.css index 977b2e44..889f7cfd 100644 --- a/src/styles/export.css +++ b/src/styles/export.css @@ -36,18 +36,11 @@ /** * Settings + * @deprecated - use tunes config instead of creating html element with controls */ .cdx-settings-button { @apply --toolbar-button; - &:not(:nth-child(3n+3)) { - margin-right: 3px; - } - - &:nth-child(n+4) { - margin-top: 3px; - } - &--active { color: var(--color-active-icon); } diff --git a/src/styles/popover.css b/src/styles/popover.css index 0b477dee..ff7fae91 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -6,6 +6,7 @@ flex-direction: column; padding: 6px; min-width: 200px; + width: 200px; overflow: hidden; box-sizing: border-box; flex-shrink: 0; @@ -42,12 +43,14 @@ } @media (--mobile) { + --offset: 5px; + position: fixed; max-width: none; - min-width: auto; - left: 5px; - right: 5px; - bottom: calc(5px + env(safe-area-inset-bottom)); + min-width: calc(100% - var(--offset) * 2); + left: var(--offset); + right: var(--offset); + bottom: calc(var(--offset) + env(safe-area-inset-bottom)); top: auto; border-radius: 10px; } @@ -64,19 +67,77 @@ &__item { @apply --popover-button; + @media (--can-hover) { + &:hover { + + &:not(.ce-popover__item--no-visible-hover) { + background-color: var(--bg-light); + } + + .ce-popover__item-icon { + box-shadow: none; + } + } + } + + &--disabled { + @apply --button-disabled; + + .ce-popover__item-icon { + box-shadow: 0 0 0 1px var(--color-line-gray); + } + } + &--focused { - @apply --button-focused; + &:not(.ce-popover__item--no-visible-focus) { + @apply --button-focused; + } } &--hidden { display: none; } + &--active { + @apply --button-active; + } + + &--confirmation { + background: var(--color-confirm); + + .ce-popover__item-icon { + color: var(--color-confirm); + } + + .ce-popover__item-label { + color: white; + } + + &:not(.ce-popover__item--no-visible-hover) { + @media (--can-hover) { + &:hover { + background: var(--color-confirm-hover); + } + } + } + + &:not(.ce-popover__item--no-visible-focus) { + &.ce-popover__item--focused { + background: var(--color-confirm-hover) !important; + } + } + + } + &-icon { @apply --tool-icon; } &-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + &::after { content: ''; width: 25px; @@ -98,6 +159,12 @@ display: none; } } + + &--confirmation, &--active, &--focused { + .ce-popover__item-icon { + box-shadow: none; + } + } } &__no-found { @@ -110,10 +177,6 @@ &--shown { display: block; } - - &:hover { - background-color: transparent; - } } @media (--mobile) { @@ -141,4 +204,17 @@ opacity: 0; visibility: hidden; } + + &__custom-content:not(:empty) { + padding: 4px; + + @media (--not-mobile) { + margin-top: 5px; + padding: 0; + } + } + + &__custom-content--hidden { + display: none; + } } diff --git a/src/styles/settings.css b/src/styles/settings.css index 78622a0b..40b4f9d6 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -1,43 +1,15 @@ .ce-settings { - @apply --overlay-pane; - top: var(--toolbar-buttons-size); - left: 0; - min-width: 114px; - box-sizing: content-box; + position: absolute; + z-index: 2; + --gap: 8px; - @media (--mobile){ - bottom: 40px; - right: auto; - top: auto; - } + @media (--not-mobile){ + position: absolute; + top: calc(var(--toolbox-buttons-size) + var(--gap)); + left: 0; - &::before{ - left: auto; - right: 12px; - - @media (--mobile){ - bottom: -5px; - top: auto; - } - } - - display: none; - - &--opened { - display: block; - animation-duration: 0.1s; - animation-name: panelShowing; - } - - &__plugin-zone { - &:not(:empty){ - padding: 3px 3px 0; - } - } - - &__default-zone { - &:not(:empty){ - padding: 3px; + &--opened-top { + top: calc(-1 * (var(--gap) + var(--popover-height))); } } diff --git a/src/styles/variables.css b/src/styles/variables.css index ca9b14b3..9012c0f9 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -34,6 +34,7 @@ /** * Gray border, loaders + * @deprecated — use --color-line-gray instead */ --color-gray-border: rgba(201, 201, 204, 0.48); @@ -69,6 +70,9 @@ * Confirm deletion bg */ --color-confirm: #E24A4A; + --color-confirm-hover: #CE4343; + + --color-line-gray: #EFF0F1; --overlay-pane: { position: absolute; @@ -104,6 +108,17 @@ background: rgba(34, 186, 255, 0.08) !important; }; + --button-active: { + background: rgba(56, 138, 229, 0.1); + color: var(--color-active-icon); + }; + + --button-disabled: { + color: var(--grayText); + cursor: default; + pointer-events: none; + } + /** * Styles for Toolbox Buttons and Plus Button */ @@ -197,12 +212,6 @@ margin-bottom: 1px; } - @media (--can-hover) { - &:hover { - background-color: var(--bg-light); - } - } - @media (--mobile) { font-size: 16px; padding: 4px; @@ -216,12 +225,12 @@ display: inline-flex; width: var(--toolbox-buttons-size); height: var(--toolbox-buttons-size); - border: 1px solid var(--color-gray-border); + box-shadow: 0 0 0 1px var(--color-gray-border); border-radius: 5px; align-items: center; justify-content: center; background: #fff; - box-sizing: border-box; + box-sizing: content-box; flex-shrink: 0; margin-right: 10px; diff --git a/test/cypress/tests/api/tools.spec.ts b/test/cypress/tests/api/tools.spec.ts index e85d7458..aa73a933 100644 --- a/test/cypress/tests/api/tools.spec.ts +++ b/test/cypress/tests/api/tools.spec.ts @@ -1,4 +1,7 @@ import { ToolboxConfig, BlockToolData, ToolboxConfigEntry } from '../../../../types'; +import { TunesMenuConfig } from '../../../../types/tools'; + +/* eslint-disable @typescript-eslint/no-empty-function */ const ICON = ''; @@ -266,4 +269,225 @@ describe('Editor Tools Api', () => { .should('not.contain', skippedEntryTitle); }); }); -}); \ No newline at end of file + + context('Tunes', () => { + it('should contain a single block tune configured in tool\'s renderSettings() method', () => { + /** Tool with single tunes menu entry configured */ + class TestTool { + /** Returns toolbox config as list of entries */ + public static get toolbox(): ToolboxConfigEntry { + return { + title: 'Test tool', + icon: ICON, + }; + } + + /** Returns configuration for block tunes menu */ + public renderSettings(): TunesMenuConfig { + return { + label: 'Test tool tune', + icon: ICON, + name: 'testToolTune', + + onActivate: (): void => {}, + }; + } + + /** Save method stub */ + public save(): void {} + + /** Renders a block */ + public render(): HTMLElement { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.setAttribute('data-name', 'testBlock'); + + return element; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + // Insert test tool block + cy.get('[data-cy=editorjs]') + .get(`[data-item-name="testTool"]`) + .click(); + + cy.get('[data-cy=editorjs]') + .get('[data-name=testBlock]') + .type('some text') + .click(); + + // Open block tunes + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + // Expect preconfigured tune to exist in tunes menu + cy.get('[data-item-name=testToolTune]').should('exist'); + }); + + it('should contain multiple block tunes if configured in tool\'s renderSettings() method', () => { + /** Tool with single tunes menu entry configured */ + class TestTool { + /** Returns toolbox config as list of entries */ + public static get toolbox(): ToolboxConfigEntry { + return { + title: 'Test tool', + icon: ICON, + }; + } + + /** Returns configuration for block tunes menu */ + public renderSettings(): TunesMenuConfig { + return [ + { + label: 'Test tool tune 1', + icon: ICON, + name: 'testToolTune1', + + onActivate: (): void => {}, + }, + { + label: 'Test tool tune 2', + icon: ICON, + name: 'testToolTune2', + + onActivate: (): void => {}, + }, + ]; + } + + /** Save method stub */ + public save(): void {} + + /** Renders a block */ + public render(): HTMLElement { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.setAttribute('data-name', 'testBlock'); + + return element; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + // Insert test tool block + cy.get('[data-cy=editorjs]') + .get(`[data-item-name="testTool"]`) + .click(); + + cy.get('[data-cy=editorjs]') + .get('[data-name=testBlock]') + .type('some text') + .click(); + + // Open block tunes + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + // Expect preconfigured tunes to exist in tunes menu + cy.get('[data-item-name=testToolTune1]').should('exist'); + cy.get('[data-item-name=testToolTune2]').should('exist'); + }); + + it('should contain block tunes represented as custom html if so configured in tool\'s renderSettings() method', () => { + const sampleText = 'sample text'; + + /** Tool with single tunes menu entry configured */ + class TestTool { + /** Returns toolbox config as list of entries */ + public static get toolbox(): ToolboxConfigEntry { + return { + title: 'Test tool', + icon: ICON, + }; + } + + /** Returns configuration for block tunes menu */ + public renderSettings(): HTMLElement { + const element = document.createElement('div'); + + element.textContent = sampleText; + + return element; + } + + /** Save method stub */ + public save(): void {} + + /** Renders a block */ + public render(): HTMLElement { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.setAttribute('data-name', 'testBlock'); + + return element; + } + } + + cy.createEditor({ + tools: { + testTool: TestTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('div.ce-toolbar__plus') + .click(); + + // Insert test tool block + cy.get('[data-cy=editorjs]') + .get(`[data-item-name="testTool"]`) + .click(); + + cy.get('[data-cy=editorjs]') + .get('[data-name=testBlock]') + .type('some text') + .click(); + + // Open block tunes + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + // Expect preconfigured custom html tunes to exist in tunes menu + cy.get('[data-cy=editorjs]') + .get('.ce-popover') + .should('contain.text', sampleText); + }); + }); +}); diff --git a/test/cypress/tests/api/tunes.spec.ts b/test/cypress/tests/api/tunes.spec.ts new file mode 100644 index 00000000..3c1721cb --- /dev/null +++ b/test/cypress/tests/api/tunes.spec.ts @@ -0,0 +1,136 @@ +import { TunesMenuConfig } from '../../../../types/tools'; + +/* eslint-disable @typescript-eslint/no-empty-function */ + +describe('Editor Tunes Api', () => { + it('should render a popover entry for block tune if configured', () => { + /** Test tune that should appear be rendered in block tunes menu */ + class TestTune { + /** Set Tool is Tune */ + public static readonly isTune = true; + + /** Tune's appearance in block settings menu */ + public render(): TunesMenuConfig { + return { + icon: 'ICON', + label: 'Test tune', + name: 'testTune', + + onActivate: (): void => { }, + }; + } + + /** Save method stub */ + public save(): void {} + } + + cy.createEditor({ + tools: { + testTune: TestTune, + }, + tunes: [ 'testTune' ], + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .type('some text') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + cy.get('[data-item-name=testTune]').should('exist'); + }); + + it('should render several popover entries for block tune if configured', () => { + /** Test tune that should appear be rendered in block tunes menu */ + class TestTune { + /** Set Tool is Tune */ + public static readonly isTune = true; + + /** Tune's appearance in block settings menu */ + public render(): TunesMenuConfig { + return [ + { + icon: 'ICON1', + label: 'Tune entry 1', + name: 'testTune1', + + onActivate: (): void => { }, + }, { + icon: 'ICON2', + label: 'Tune entry 2', + name: 'testTune2', + + onActivate: (): void => { }, + }, + ]; + } + + /** Save method stub */ + public save(): void {} + } + + cy.createEditor({ + tools: { + testTune: TestTune, + }, + tunes: [ 'testTune' ], + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .type('some text') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + cy.get('[data-item-name=testTune1]').should('exist'); + cy.get('[data-item-name=testTune2]').should('exist'); + }); + + it('should display custom html returned by tune\'s render() method inside tunes menu', () => { + const sampleText = 'sample text'; + + /** Test tune that should appear be rendered in block tunes menu */ + class TestTune { + /** Set Tool is Tune */ + public static readonly isTune = true; + + /** Tune's appearance in block settings menu */ + public render(): HTMLElement { + const element = document.createElement('div'); + + element.textContent = sampleText; + + return element; + } + + /** Save method stub */ + public save(): void {} + } + + cy.createEditor({ + tools: { + testTune: TestTune, + }, + tunes: [ 'testTune' ], + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .type('some text') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-popover') + .should('contain.text', sampleText); + }); +}); diff --git a/test/cypress/tests/onchange.spec.ts b/test/cypress/tests/onchange.spec.ts index f4740c30..4e275d85 100644 --- a/test/cypress/tests/onchange.spec.ts +++ b/test/cypress/tests/onchange.spec.ts @@ -262,8 +262,12 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-settings__button--delete') - .click() + .get('div[data-item-name=delete]') + .click(); + + /** Second click for confirmation */ + cy.get('[data-cy=editorjs]') + .get('div[data-item-name=delete]') .click(); cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({ @@ -292,7 +296,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-tune-move-up') + .get('div[data-item-name=move-up]') .click(); cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({ diff --git a/test/cypress/tests/utils/flipper.spec.ts b/test/cypress/tests/utils/flipper.spec.ts new file mode 100644 index 00000000..6ca1c518 --- /dev/null +++ b/test/cypress/tests/utils/flipper.spec.ts @@ -0,0 +1,97 @@ +import { PopoverItem } from '../../../../types/index.js'; + +/** + * Mock of some Block Tool + */ +class SomePlugin { + /** + * Event handler to be spyed in test + */ + // eslint-disable-next-line @typescript-eslint/no-empty-function + public static pluginInternalKeydownHandler(): void {} + + /** + * Mocked render method + */ + public render(): HTMLElement { + const wrapper = document.createElement('div'); + + wrapper.classList.add('cdx-some-plugin'); + wrapper.contentEditable = 'true'; + wrapper.addEventListener('keydown', SomePlugin.pluginInternalKeydownHandler); + + return wrapper; + } + + /** + * Used to display our tool in the Toolboz + */ + public static get toolbox(): PopoverItem { + return { + icon: '₷', + label: 'Some tool', + // eslint-disable-next-line @typescript-eslint/no-empty-function + onActivate: (): void => {}, + }; + } +} + +describe('Flipper', () => { + beforeEach(() => { + if (this && this.editorInstance) { + this.editorInstance.destroy(); + } else { + cy.createEditor({ + tools: { + sometool: SomePlugin, + }, + }).as('editorInstance'); + } + }); + + it('should prevent plugins event handlers from being called while keyboard navigation', () => { + const TAB_KEY_CODE = 9; + const ARROW_DOWN_KEY_CODE = 40; + const ENTER_KEY_CODE = 13; + + const sampleText = 'sample text'; + + cy.spy(SomePlugin, 'pluginInternalKeydownHandler'); + + // Insert sometool block and enter sample text + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .trigger('keydown', { keyCode: TAB_KEY_CODE }); + + cy.get('[data-item-name=sometool]').click(); + + cy.get('[data-cy=editorjs]') + .get('.cdx-some-plugin') + .focus() + .type(sampleText); + + // Try to delete the block via keyboard + cy.get('[data-cy=editorjs]') + .get('.cdx-some-plugin') + // Open tunes menu + .trigger('keydown', { keyCode: TAB_KEY_CODE }) + // Navigate to delete button (the second button) + .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }) + .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }); + + /** + * Check whether we focus the Delete Tune or not + */ + cy.get('[data-item-name="delete"]') + .should('have.class', 'ce-popover__item--focused'); + + cy.get('[data-cy=editorjs]') + .get('.cdx-some-plugin') + // Click delete + .trigger('keydown', { keyCode: ENTER_KEY_CODE }) + // // Confirm delete + .trigger('keydown', { keyCode: ENTER_KEY_CODE }); + + expect(SomePlugin.pluginInternalKeydownHandler).to.have.not.been.called; + }); +}); diff --git a/test/cypress/tests/utils/popover.spec.ts b/test/cypress/tests/utils/popover.spec.ts new file mode 100644 index 00000000..d0191da4 --- /dev/null +++ b/test/cypress/tests/utils/popover.spec.ts @@ -0,0 +1,188 @@ +import Popover from '../../../../src/components/utils/popover'; +import { PopoverItem } from '../../../../types'; + +/* eslint-disable @typescript-eslint/no-empty-function */ + +describe('Popover', () => { + it('should support confirmation chains', () => { + const actionIcon = 'Icon 1'; + const actionLabel = 'Action'; + const confirmActionIcon = 'Icon 2'; + const confirmActionLabel = 'Confirm action'; + + /** + * Confirmation is moved to separate variable to be able to test it's callback execution. + * (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise) + */ + const confirmation = { + icon: confirmActionIcon, + label: confirmActionLabel, + onActivate: cy.stub(), + }; + + const items: PopoverItem[] = [ + { + icon: actionIcon, + label: actionLabel, + name: 'testItem', + confirmation, + }, + ]; + + const popover = new Popover({ + items, + filterLabel: '', + nothingFoundLabel: '', + scopeElement: null, + }); + + cy.document().then(doc => { + doc.body.append(popover.getElement()); + + cy.get('[data-item-name=testItem]') + .get('.ce-popover__item-icon') + .should('have.text', actionIcon); + + cy.get('[data-item-name=testItem]') + .get('.ce-popover__item-label') + .should('have.text', actionLabel); + + // First click on item + cy.get('[data-item-name=testItem]').click(); + + // Check icon has changed + cy.get('[data-item-name=testItem]') + .get('.ce-popover__item-icon') + .should('have.text', confirmActionIcon); + + // Check label has changed + cy.get('[data-item-name=testItem]') + .get('.ce-popover__item-label') + .should('have.text', confirmActionLabel); + + // Second click + cy.get('[data-item-name=testItem]') + .click() + .then(() => { + // Check onActivate callback has been called + expect(confirmation.onActivate).to.have.been.calledOnce; + }); + }); + }); + + it('should render the items with true isActive property value as active', () => { + const items: PopoverItem[] = [ + { + icon: 'Icon', + label: 'Label', + isActive: true, + name: 'testItem', + onActivate: (): void => {}, + }, + ]; + + const popover = new Popover({ + items, + filterLabel: '', + nothingFoundLabel: '', + scopeElement: null, + }); + + cy.document().then(doc => { + doc.body.append(popover.getElement()); + + /* Check item has active class */ + cy.get('[data-item-name=testItem]') + .should('have.class', 'ce-popover__item--active'); + }); + }); + + it('should not execute item\'s onActivate callback if the item is disabled', () => { + const items: PopoverItem[] = [ + { + icon: 'Icon', + label: 'Label', + isDisabled: true, + name: 'testItem', + onActivate: cy.stub(), + }, + ]; + + const popover = new Popover({ + items, + filterLabel: '', + nothingFoundLabel: '', + scopeElement: null, + }); + + cy.document().then(doc => { + doc.body.append(popover.getElement()); + + /* Check item has disabled class */ + cy.get('[data-item-name=testItem]') + .should('have.class', 'ce-popover__item--disabled') + .click() + .then(() => { + // Check onActivate callback has never been called + expect(items[0].onActivate).to.have.not.been.called; + }); + }); + }); + + it('should close once item with closeOnActivate property set to true is activated', () => { + const items: PopoverItem[] = [ + { + icon: 'Icon', + label: 'Label', + closeOnActivate: true, + name: 'testItem', + onActivate: (): void => {}, + }, + ]; + const popover = new Popover({ + items, + filterLabel: '', + nothingFoundLabel: '', + scopeElement: null, + }); + + cy.spy(popover, 'hide'); + + cy.document().then(doc => { + doc.body.append(popover.getElement()); + + cy.get('[data-item-name=testItem]') + .click() + .then(() => { + expect(popover.hide).to.have.been.called; + }); + }); + }); + + it('should highlight as active the item with toggle property set to true once activated', () => { + const items: PopoverItem[] = [ + { + icon: 'Icon', + label: 'Label', + toggle: true, + name: 'testItem', + onActivate: (): void => {}, + }, + ]; + const popover = new Popover({ + items, + filterLabel: '', + nothingFoundLabel: '', + scopeElement: null, + }); + + cy.document().then(doc => { + doc.body.append(popover.getElement()); + + /* Check item has active class */ + cy.get('[data-item-name=testItem]') + .click() + .should('have.class', 'ce-popover__item--active'); + }); + }); +}); diff --git a/test/cypress/tsconfig.json b/test/cypress/tsconfig.json index a996c048..4d48eb0f 100644 --- a/test/cypress/tsconfig.json +++ b/test/cypress/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "es2017", - "lib": ["dom", "es2017", "es2018"], + "lib": ["dom", "es2017", "es2018", "es2019"], "moduleResolution": "node", "resolveJsonModule": true, "allowSyntheticDefaultImports": true, diff --git a/tsconfig.json b/tsconfig.json index c95c063b..c4e387d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "es2017", "declaration": false, "moduleResolution": "node", // This resolution strategy attempts to mimic the Node.js module resolution mechanism at runtime - "lib": ["dom", "es2017", "es2018"], + "lib": ["dom", "es2017", "es2018", "es2019"], // allows to import .json files for i18n "resolveJsonModule": true, diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index 21649db8..f684bf78 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -52,7 +52,7 @@ export interface Blocks { * Returns Block API object by passed Block index * @param {number} index */ - getBlockByIndex(index: number): BlockAPI | void; + getBlockByIndex(index: number): BlockAPI | undefined; /** * Returns Block API object by passed Block id diff --git a/types/block-tunes/block-tune.d.ts b/types/block-tunes/block-tune.d.ts index f323fd82..70169d82 100644 --- a/types/block-tunes/block-tune.d.ts +++ b/types/block-tunes/block-tune.d.ts @@ -1,5 +1,6 @@ import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index'; import { BlockTuneData } from './block-tune-data'; +import { TunesMenuConfig } from '../tools'; /** * Describes BLockTune blueprint @@ -7,10 +8,8 @@ import { BlockTuneData } from './block-tune-data'; export interface BlockTune { /** * Returns block tune HTMLElement - * - * @return {HTMLElement} */ - render(): HTMLElement; + render(): HTMLElement | TunesMenuConfig; /** * Method called on Tool render. Pass Tool content as an argument. diff --git a/types/configs/index.d.ts b/types/configs/index.d.ts index 20723a1d..d1d20a7e 100644 --- a/types/configs/index.d.ts +++ b/types/configs/index.d.ts @@ -1,3 +1,5 @@ +import { fromCallback } from 'cypress/types/bluebird'; + export * from './editor-config'; export * from './sanitizer-config'; export * from './paste-config'; @@ -5,3 +7,4 @@ export * from './conversion-config'; export * from './log-levels'; export * from './i18n-config'; export * from './i18n-dictionary'; +export * from './popover' diff --git a/types/configs/popover.d.ts b/types/configs/popover.d.ts new file mode 100644 index 00000000..5002464e --- /dev/null +++ b/types/configs/popover.d.ts @@ -0,0 +1,79 @@ +/** + * Common parameters for both types of popover items: with or without confirmation + */ +interface PopoverItemBase { + /** + * Item icon to be appeared near a title + */ + icon: string; + + /** + * Displayed text + */ + label: string; + + /** + * Additional displayed text + */ + secondaryLabel?: string; + + /** + * True if item should be highlighted as active + */ + isActive?: boolean; + + /** + * True if item should be disabled + */ + isDisabled?: boolean; + + /** + * True if popover should close once item is activated + */ + closeOnActivate?: boolean; + + /** + * Item name + * Used in data attributes needed for cypress tests + */ + name?: string; + + /** + * True if item should be highlighted once activated + */ + toggle?: boolean; +} + +/** + * Represents popover item with confirmation state configuration + */ +export interface PopoverItemWithConfirmation extends PopoverItemBase { + /** + * Popover item parameters that should be applied on item activation. + * May be used to ask user for confirmation before executing popover item activation handler. + */ + confirmation: Partial; + + onActivate?: never; +} + +/** + * Represents default popover item without confirmation state configuration + */ +export interface PopoverItemWithoutConfirmation extends PopoverItemBase { + confirmation?: never; + + /** + * Popover item activation handler + * + * @param item - activated item + * @param event - event that initiated item activation + */ + onActivate: (item: PopoverItem, event?: PointerEvent) => void; +} + +/** + * Represents single popover item + */ +export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation + diff --git a/types/index.d.ts b/types/index.d.ts index f9deaf8c..52108034 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -71,6 +71,9 @@ export { Dictionary, DictValue, I18nConfig, + PopoverItem, + PopoverItemWithConfirmation, + PopoverItemWithoutConfirmation } from './configs'; export {OutputData, OutputBlockData} from './data-formats/output-data'; export { BlockAPI } from './api' diff --git a/types/tools/block-tool.d.ts b/types/tools/block-tool.d.ts index afc2a0ed..8c9bb858 100644 --- a/types/tools/block-tool.d.ts +++ b/types/tools/block-tool.d.ts @@ -5,6 +5,7 @@ import { ToolConfig } from './tool-config'; import { API, BlockAPI, ToolboxConfig } from '../index'; import { PasteEvent } from './paste-events'; import { MoveEvent } from './hook-events'; +import { TunesMenuConfig } from './tool-settings'; /** * Describe Block Tool object @@ -25,9 +26,8 @@ export interface BlockTool extends BaseTool { /** * Create Block's settings block - * @return {HTMLElement} */ - renderSettings?(): HTMLElement; + renderSettings?(): HTMLElement | TunesMenuConfig; /** * Validate Block's data diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index c6d61cdf..c9771627 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -1,5 +1,6 @@ import { ToolConfig } from './tool-config'; import { ToolConstructable, BlockToolData } from './index'; +import { PopoverItem } from '../configs'; /** * Tool may specify its toolbox configuration @@ -27,6 +28,12 @@ export interface ToolboxConfigEntry { data?: BlockToolData } +/** + * Tool may specify its tunes configuration + * that can contain either one or multiple entries + */ +export type TunesMenuConfig = PopoverItem | PopoverItem[]; + /** * Object passed to the Tool's constructor by {@link EditorConfig#tools} *