diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c93c90f9..b1ff985d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -### 2.23.3 +### 2.24.0 +- `New` — *UI* — The Toolbox became vertical 🥳 +- `Improvement` — *UI* — the Plus button will always be shown (previously, it appears only for empty blocks) - `Improvement` — *Dev Example Page* - Server added to allow opening example page on other devices in network. +- `Fix` - `UI` - the Toolbar won't move on hover at mobile viewports. Resolves [#1972](https://github.com/codex-team/editor.js/issues/1972) - `Fix` — `OnChange` event invocation after block insertion. [#1997](https://github.com/codex-team/editor.js/issues/1997) ### 2.23.2 @@ -435,4 +438,4 @@ See a whole [Changelog](/docs/) - `New` New [Editor.js PHP](http://github.com/codex-team/codex.editor.backend) — example of server-side implementation with HTML purifying and data validation. - `Improvements` - Improvements of Toolbar's position calculation. - `Improvements` — Improved zero-configuration initialization. -- and many little improvements. \ No newline at end of file +- and many little improvements. diff --git a/example/example-i18n.html b/example/example-i18n.html index b07a6722..0496fbbc 100644 --- a/example/example-i18n.html +++ b/example/example-i18n.html @@ -193,7 +193,9 @@ }, "toolbar": { "toolbox": { - "Add": "Добавить" + "Add": "Добавить", + "Filter": "Поиск", + "Noting found": "Ничего не найдено" } } }, diff --git a/src/assets/search.svg b/src/assets/search.svg new file mode 100644 index 00000000..1485338b --- /dev/null +++ b/src/assets/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/flipper.ts b/src/components/flipper.ts index a97216a6..7ab00fbb 100644 --- a/src/components/flipper.ts +++ b/src/components/flipper.ts @@ -17,21 +17,23 @@ export interface FlipperOptions { */ items?: HTMLElement[]; - /** - * Defines arrows usage. By default Flipper leafs items also via RIGHT/LEFT. - * - * true by default - * - * Pass 'false' if you don't need this behaviour - * (for example, Inline Toolbar should be closed by arrows, - * because it means caret moving with selection clearing) - */ - allowArrows?: boolean; - /** * Optional callback for button click */ activateCallback?: (item: HTMLElement) => void; + + /** + * List of keys allowed for handling. + * Can include codes of the following keys: + * - Tab + * - Enter + * - Arrow up + * - Arrow down + * - Arrow right + * - Arrow left + * If not specified all keys are enabled + */ + allowedKeys?: number[]; } /** @@ -53,11 +55,9 @@ export default class Flipper { private activated = false; /** - * Flag that allows arrows usage to flip items - * - * @type {boolean} + * List codes of the keys allowed for handling */ - private readonly allowArrows: boolean = true; + private readonly allowedKeys: number[]; /** * Call back for button click/enter @@ -68,9 +68,9 @@ export default class Flipper { * @param {FlipperOptions} options - different constructing settings */ constructor(options: FlipperOptions) { - this.allowArrows = _.isBoolean(options.allowArrows) ? options.allowArrows : true; this.iterator = new DomIterator(options.items, options.focusedItemClass); this.activateCallback = options.activateCallback; + this.allowedKeys = options.allowedKeys || Flipper.usedKeys; } /** @@ -120,15 +120,6 @@ export default class Flipper { document.removeEventListener('keydown', this.onKeyDown); } - /** - * Return current focused button - * - * @returns {HTMLElement|null} - */ - public get currentItem(): HTMLElement|null { - return this.iterator.currentItem; - } - /** * Focus first item */ @@ -142,6 +133,7 @@ export default class Flipper { */ public flipLeft(): void { this.iterator.previous(); + this.flipCallback(); } /** @@ -149,6 +141,14 @@ export default class Flipper { */ public flipRight(): void { this.iterator.next(); + this.flipCallback(); + } + + /** + * Return true if some button is focused + */ + public hasFocus(): boolean { + return !!this.iterator.currentItem; } /** @@ -206,23 +206,7 @@ export default class Flipper { * @returns {boolean} */ private isEventReadyForHandling(event: KeyboardEvent): boolean { - const handlingKeyCodeList = [ - _.keyCodes.TAB, - _.keyCodes.ENTER, - ]; - - const isCurrentItemIsFocusedInput = this.iterator.currentItem == document.activeElement; - - if (this.allowArrows && !isCurrentItemIsFocusedInput) { - handlingKeyCodeList.push( - _.keyCodes.LEFT, - _.keyCodes.RIGHT, - _.keyCodes.UP, - _.keyCodes.DOWN - ); - } - - return this.activated && handlingKeyCodeList.indexOf(event.keyCode) !== -1; + return this.activated && this.allowedKeys.includes(event.keyCode); } /** @@ -266,4 +250,13 @@ export default class Flipper { event.preventDefault(); event.stopPropagation(); } + + /** + * Fired after flipping in any direction + */ + private flipCallback(): void { + if (this.iterator.currentItem) { + this.iterator.currentItem.scrollIntoViewIfNeeded(); + } + } } diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json index f02cac61..42a1520b 100644 --- a/src/components/i18n/locales/en/messages.json +++ b/src/components/i18n/locales/en/messages.json @@ -13,7 +13,9 @@ }, "toolbar": { "toolbox": { - "Add": "" + "Add": "", + "Filter": "", + "Noting found": "" } } }, diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index f75f3019..be5a16a5 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -33,7 +33,6 @@ export default class Saver extends Module { chainData = []; try { - blocks.forEach((block: Block) => { chainData.push(this.getSavedData(block)); }); @@ -46,7 +45,7 @@ export default class Saver extends Module { return this.makeOutput(sanitizedData); } catch (e) { _.logLabeled(`Saving failed due to the Error %o`, 'error', e); - } + } } /** diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 864ec85b..df48ad3d 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -13,6 +13,10 @@ import Toolbox, { ToolboxEvent } from '../../ui/toolbox'; * @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set) * - make Block Settings a standalone module * + * @todo - Keyboard-only mode bug: + * press Tab, flip to the Checkbox. press Enter (block will be added), Press Tab + * (Block Tunes will be opened with Move up focused), press Enter, press Tab ———— both Block Tunes and Toolbox will be opened + * * @todo TESTCASE - show toggler after opening and closing the Inline Toolbar * @todo TESTCASE - Click outside Editor holder should close Toolbar and Clear Focused blocks * @todo TESTCASE - Click inside Editor holder should close Toolbar and Clear Focused blocks @@ -33,11 +37,7 @@ interface ToolbarNodes { content: HTMLElement; actions: HTMLElement; - // Content Zone plusButton: HTMLElement; - - // Actions Zone - blockActionsButtons: HTMLElement; settingsToggler: HTMLElement; } /** @@ -137,14 +137,10 @@ export default class Toolbar extends Module { toolbarOpened: 'ce-toolbar--opened', openedToolboxHolderModifier: 'codex-editor--toolbox-opened', - // Content Zone plusButton: 'ce-toolbar__plus', plusButtonShortcut: 'ce-toolbar__plus-shortcut', - plusButtonHidden: 'ce-toolbar__plus--hidden', - - // Actions Zone - blockActionsButtons: 'ce-toolbar__actions-buttons', settingsToggler: 'ce-toolbar__settings-btn', + settingsTogglerHidden: 'ce-toolbar__settings-btn--hidden', }; } @@ -157,23 +153,6 @@ export default class Toolbar extends Module { return this.nodes.wrapper.classList.contains(this.CSS.toolbarOpened); } - /** - * Plus Button public methods - * - * @returns {{hide: function(): void, show: function(): void}} - */ - public get plusButton(): { hide: () => void; show: () => void } { - return { - hide: (): void => this.nodes.plusButton.classList.add(this.CSS.plusButtonHidden), - show: (): void => { - if (this.toolboxInstance.isEmpty) { - return; - } - this.nodes.plusButton.classList.remove(this.CSS.plusButtonHidden); - }, - }; - } - /** * Public interface for accessing the Toolbox */ @@ -182,11 +161,14 @@ export default class Toolbar extends Module { close: () => void; open: () => void; toggle: () => void; - flipperHasFocus: boolean; + hasFocus: () => boolean; } { return { opened: this.toolboxInstance.opened, - close: (): void => this.toolboxInstance.close(), + close: (): void => { + this.toolboxInstance.close(); + this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock); + }, open: (): void => { /** * Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block. @@ -196,14 +178,12 @@ export default class Toolbar extends Module { this.toolboxInstance.open(); }, toggle: (): void => this.toolboxInstance.toggle(), - flipperHasFocus: this.toolboxInstance.flipperHasFocus, + hasFocus: (): boolean => this.toolboxInstance.hasFocus(), }; } /** * Block actions appearance manipulations - * - * @returns {{hide: function(): void, show: function(): void}} */ private get blockActions(): { hide: () => void; show: () => void } { return { @@ -216,6 +196,16 @@ export default class Toolbar extends Module { }; } + /** + * Methods for working with Block Tunes toggler + */ + private get blockTunesToggler(): { hide: () => void; show: () => void } { + return { + hide: (): void => this.nodes.settingsToggler.classList.add(this.CSS.settingsTogglerHidden), + show: (): void => this.nodes.settingsToggler.classList.remove(this.CSS.settingsTogglerHidden), + }; + } + /** * Toggles read-only mode * @@ -276,15 +266,15 @@ export default class Toolbar extends Module { /** * Move Toolbar to the Top coordinate of Block */ - this.nodes.wrapper.style.transform = `translate3D(0, ${Math.floor(toolbarY)}px, 0)`; + this.nodes.wrapper.style.top = `${Math.floor(toolbarY)}px`; /** - * Plus Button should be shown only for __empty__ __default__ block + * Do not show Block Tunes Toggler near single and empty block */ - if (block.tool.isDefault && block.isEmpty) { - this.plusButton.show(); + if (this.Editor.BlockManager.blocks.length === 1 && block.isEmpty) { + this.blockTunesToggler.hide(); } else { - this.plusButton.hide(); + this.blockTunesToggler.show(); } this.open(); @@ -381,13 +371,11 @@ export default class Toolbar extends Module { * - Remove Block Button * - Settings Panel */ - this.nodes.blockActionsButtons = $.make('div', this.CSS.blockActionsButtons); this.nodes.settingsToggler = $.make('span', this.CSS.settingsToggler); const settingsIcon = $.svg('dots', 16, 16); $.append(this.nodes.settingsToggler, settingsIcon); - $.append(this.nodes.blockActionsButtons, this.nodes.settingsToggler); - $.append(this.nodes.actions, this.nodes.blockActionsButtons); + $.append(this.nodes.actions, this.nodes.settingsToggler); this.tooltip.onHover( this.nodes.settingsToggler, @@ -400,7 +388,7 @@ export default class Toolbar extends Module { /** * Appending Toolbar components to itself */ - $.append(this.nodes.content, this.makeToolbox()); + $.append(this.nodes.actions, this.makeToolbox()); $.append(this.nodes.actions, this.Editor.BlockSettings.nodes.wrapper); /** @@ -419,6 +407,10 @@ export default class Toolbar extends Module { this.toolboxInstance = new Toolbox({ 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, 'Noting found'), + }, }); this.toolboxInstance.on(ToolboxEvent.Opened, () => { @@ -487,18 +479,25 @@ export default class Toolbar extends Module { }, true); /** - * Subscribe to the 'block-hovered' event + * Subscribe to the 'block-hovered' event if currenct view is not mobile + * + * @see https://github.com/codex-team/editor.js/issues/1972 */ - this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => { + if (!_.isMobileScreen()) { /** - * Do not move toolbar if Block Settings or Toolbox opened + * Subscribe to the 'block-hovered' event */ - if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) { - return; - } + this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => { + /** + * Do not move toolbar if Block Settings or Toolbox opened + */ + if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) { + return; + } - this.moveAndOpen(data.block); - }); + this.moveAndOpen(data.block); + }); + } } /** diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 91ded10d..799b60fd 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -698,7 +698,10 @@ export default class InlineToolbar extends Module { private enableFlipper(): void { this.flipper = new Flipper({ focusedItemClass: this.CSS.focusedButton, - allowArrows: false, + allowedKeys: [ + _.keyCodes.ENTER, + _.keyCodes.TAB, + ], }); } } diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index aecd6de5..75bfef90 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -231,7 +231,7 @@ export default class UI extends Module { * Toolbar has internal module (Toolbox) that has own Flipper, * so we check it manually */ - if (this.Editor.Toolbar.toolbox.flipperHasFocus) { + if (this.Editor.Toolbar.toolbox.hasFocus()) { return true; } @@ -239,7 +239,7 @@ export default class UI extends Module { return moduleClass.flipper instanceof Flipper; }) .some(([moduleName, moduleClass]) => { - return moduleClass.flipper.currentItem; + return moduleClass.flipper.hasFocus(); }); } @@ -385,7 +385,7 @@ export default class UI extends Module { */ private watchBlockHoveredEvents(): void { /** - * Used to not to emit the same block multiple times to the 'block-hovered' event on every mousemove + * Used to not emit the same block multiple times to the 'block-hovered' event on every mousemove */ let blockHoveredEmitted; diff --git a/src/components/polyfills.ts b/src/components/polyfills.ts index cfa9af74..42b86a05 100644 --- a/src/components/polyfills.ts +++ b/src/components/polyfills.ts @@ -96,3 +96,46 @@ if (!Element.prototype.prepend) { this.insertBefore(docFrag, this.firstChild); }; } + +interface Element { + /** + * Scrolls the current element into the visible area of the browser window + * + * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor. + */ + scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void; +} + +/** + * ScrollIntoViewIfNeeded polyfill by KilianSSL (forked from hsablonniere) + * + * @see {@link https://gist.github.com/KilianSSL/774297b76378566588f02538631c3137} + * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor. + */ +if (!Element.prototype.scrollIntoViewIfNeeded) { + Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded): void { + centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded; + + const parent = this.parentNode, + parentComputedStyle = window.getComputedStyle(parent, null), + parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')), + parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')), + overTop = this.offsetTop - parent.offsetTop < parent.scrollTop, + overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight), + overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft, + overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth), + alignWithTop = overTop && !overBottom; + + if ((overTop || overBottom) && centerIfNeeded) { + parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2; + } + + if ((overLeft || overRight) && centerIfNeeded) { + parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2; + } + + if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) { + this.scrollIntoView(alignWithTop); + } + }; +} diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 2c813caf..89da4215 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -1,15 +1,15 @@ -import $ from '../dom'; import * as _ from '../utils'; -import Flipper from '../flipper'; import { BlockToolAPI } from '../block'; -import I18n from '../i18n'; -import { I18nInternalNS } from '../i18n/namespace-internal'; import Shortcuts from '../utils/shortcuts'; -import Tooltip from '../utils/tooltip'; import BlockTool from '../tools/block'; import ToolsCollection from '../tools/collection'; import { API } from '../../../types'; import EventsDispatcher from '../utils/events'; +import Popover, { PopoverEvent } from '../utils/popover'; + +/** + * @todo the first Tab on the Block — focus Plus Button, the second — focus Block Tunes Toggler, the third — focus next Block + */ /** * Event that can be triggered by the Toolbox @@ -31,6 +31,11 @@ export enum ToolboxEvent { BlockAdded = 'toolbox-block-added', } +/** + * Available i18n dict keys that should be passed to the constructor + */ +type toolboxTextLabelsKeys = 'filter' | 'nothingFound'; + /** * Toolbox * This UI element contains list of Block Tools available to be inserted @@ -45,7 +50,7 @@ export default class Toolbox extends EventsDispatcher { * @returns {boolean} */ public get isEmpty(): boolean { - return this.displayedToolsCount === 0; + return this.toolsToBeDisplayed.length === 0; } /** @@ -60,21 +65,29 @@ export default class Toolbox extends EventsDispatcher { */ private api: API; + /** + * Popover instance. There is a util for vertical lists. + */ + private popover: Popover; + /** * List of Tools available. Some of them will be shown in the Toolbox */ private tools: ToolsCollection; + /** + * Text labels used in the Toolbox. Should be passed from the i18n module + */ + private i18nLabels: Record; + /** * Current module HTML Elements */ private nodes: { toolbox: HTMLElement; - buttons: HTMLElement[]; } = { toolbox: null, - buttons: [], - } + }; /** * CSS styles @@ -84,34 +97,10 @@ export default class Toolbox extends EventsDispatcher { private static get CSS(): { [name: string]: string } { return { toolbox: 'ce-toolbox', - toolboxButton: 'ce-toolbox__button', - toolboxButtonActive: 'ce-toolbox__button--active', - toolboxOpened: 'ce-toolbox--opened', - - buttonTooltip: 'ce-toolbox-button-tooltip', - buttonShortcut: 'ce-toolbox-button-tooltip__shortcut', + toolboxOpenedTop: 'ce-toolbox--opened-top', }; } - /** - * How many tools displayed in Toolbox - * - * @type {number} - */ - private displayedToolsCount = 0; - - /** - * Instance of class that responses for leafing buttons by arrows/tab - * - * @type {Flipper|null} - */ - private flipper: Flipper = null; - - /** - * Tooltip utility Instance - */ - private tooltip: Tooltip; - /** * Id of listener added used to remove it on destroy() */ @@ -124,67 +113,78 @@ export default class Toolbox extends EventsDispatcher { * @param options.api - Editor API methods * @param options.tools - Tools available to check whether some of them should be displayed at the Toolbox or not */ - constructor({ api, tools }) { + constructor({ api, tools, i18nLabels }: {api: API; tools: ToolsCollection; i18nLabels: Record}) { super(); this.api = api; this.tools = tools; - - this.tooltip = new Tooltip(); - } - - /** - * Returns true if the Toolbox has the Flipper activated and the Flipper has selected button - */ - public get flipperHasFocus(): boolean { - return this.flipper && this.flipper.currentItem !== null; + this.i18nLabels = i18nLabels; } /** * Makes the Toolbox */ public make(): Element { - this.nodes.toolbox = $.make('div', Toolbox.CSS.toolbox); + this.popover = new Popover({ + className: Toolbox.CSS.toolbox, + searchable: true, + filterLabel: this.i18nLabels.filter, + nothingFoundLabel: this.i18nLabels.nothingFound, + items: this.toolsToBeDisplayed.map(tool => { + return { + icon: tool.toolbox.icon, + label: tool.toolbox.title, + name: tool.name, + onClick: (item): void => { + this.toolButtonActivated(tool.name); + }, + secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '', + }; + }), + }); - this.addTools(); - this.enableFlipper(); + this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked); + + /** + * Enable tools shortcuts + */ + this.enableShortcuts(); + + this.nodes.toolbox = this.popover.getElement(); return this.nodes.toolbox; } + /** + * Returns true if the Toolbox has the Flipper activated and the Flipper has selected button + */ + public hasFocus(): boolean { + return this.popover.hasFocus(); + } + /** * Destroy Module */ public destroy(): void { super.destroy(); - /** - * Sometimes (in read-only mode) there is no Flipper - */ - if (this.flipper) { - this.flipper.deactivate(); - this.flipper = null; - } - if (this.nodes && this.nodes.toolbox) { this.nodes.toolbox.remove(); this.nodes.toolbox = null; - this.nodes.buttons = []; } this.api.listeners.offById(this.clickListenerId); this.removeAllShortcuts(); - this.tooltip.destroy(); + this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked); } /** * Toolbox Tool's button click handler * - * @param {MouseEvent|KeyboardEvent} event - event that activates toolbox button - * @param {string} toolName - button to activate + * @param toolName - tool type to be activated */ - public toolButtonActivate(event: MouseEvent|KeyboardEvent, toolName: string): void { + public toolButtonActivated(toolName: string): void { this.insertNewBlock(toolName); } @@ -196,24 +196,28 @@ export default class Toolbox extends EventsDispatcher { return; } - this.emit(ToolboxEvent.Opened); - - this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpened); + /** + * 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.flipper.activate(); + this.emit(ToolboxEvent.Opened); } /** * Close Toolbox */ public close(): void { - this.emit(ToolboxEvent.Closed); - - this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpened); - + this.popover.hide(); this.opened = false; - this.flipper.deactivate(); + this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop); + this.emit(ToolboxEvent.Closed); } /** @@ -228,106 +232,65 @@ export default class Toolbox extends EventsDispatcher { } /** - * Iterates available tools and appends them to the Toolbox + * Checks if there popover should be opened downwards. + * It happens in case there is enough space below or not enough space above */ - private addTools(): void { - Array + 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 + */ + private onOverlayClicked = (): void => { + this.close(); + } + + /** + * Returns list of tools that enables the Toolbox (by specifying the 'toolbox' getter) + */ + @_.cacheable + private get toolsToBeDisplayed(): BlockTool[] { + return Array .from(this.tools.values()) - .forEach((tool) => this.addTool(tool)); + .filter(tool => { + const toolToolboxSettings = tool.toolbox; + + /** + * Skip tools that don't pass 'toolbox' property + */ + if (!toolToolboxSettings) { + return false; + } + + if (toolToolboxSettings && !toolToolboxSettings.icon) { + _.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name); + + return false; + } + + return true; + }); } /** - * Append Tool to the Toolbox - * - * @param {BlockToolConstructable} tool - BlockTool object + * Iterate all tools and enable theirs shortcuts if specified */ - private addTool(tool: BlockTool): void { - const toolToolboxSettings = tool.toolbox; + private enableShortcuts(): void { + this.toolsToBeDisplayed.forEach((tool: BlockTool) => { + const shortcut = tool.shortcut; - /** - * Skip tools that don't pass 'toolbox' property - */ - if (!toolToolboxSettings) { - return; - } - - if (toolToolboxSettings && !toolToolboxSettings.icon) { - _.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name); - - return; - } - - /** - * @todo Add checkup for the render method - */ - // if (typeof tool.render !== 'function') { - // _.log('render method missed. Tool %o skipped', 'warn', tool); - // return; - // } - - const button = $.make('li', [ Toolbox.CSS.toolboxButton ]); - - button.dataset.tool = tool.name; - button.innerHTML = toolToolboxSettings.icon; - - $.append(this.nodes.toolbox, button); - - this.nodes.toolbox.appendChild(button); - this.nodes.buttons.push(button); - - /** - * Add click listener - */ - this.clickListenerId = this.api.listeners.on(button, 'click', (event: KeyboardEvent|MouseEvent) => { - this.toolButtonActivate(event, tool.name); + if (shortcut) { + this.enableShortcutForTool(tool.name, shortcut); + } }); - - /** - * Add listeners to show/hide toolbox tooltip - */ - const tooltipContent = this.drawTooltip(tool); - - this.tooltip.onHover(button, tooltipContent, { - placement: 'bottom', - hidingDelay: 200, - }); - - const shortcut = tool.shortcut; - - if (shortcut) { - this.enableShortcut(tool.name, shortcut); - } - - /** Increment Tools count */ - this.displayedToolsCount++; - } - - /** - * Draw tooltip for toolbox tools - * - * @param tool - BlockTool object - * @returns {HTMLElement} - */ - private drawTooltip(tool: BlockTool): HTMLElement { - const toolboxSettings = tool.toolbox || {}; - const name = I18n.t(I18nInternalNS.toolNames, toolboxSettings.title || tool.name); - - let shortcut = tool.shortcut; - - const tooltip = $.make('div', Toolbox.CSS.buttonTooltip); - const hint = document.createTextNode(_.capitalize(name)); - - tooltip.appendChild(hint); - - if (shortcut) { - shortcut = _.beautifyShortcut(shortcut); - - tooltip.appendChild($.make('div', Toolbox.CSS.buttonShortcut, { - textContent: shortcut, - })); - } - - return tooltip; } /** @@ -336,7 +299,7 @@ export default class Toolbox extends EventsDispatcher { * @param {string} toolName - Tool name * @param {string} shortcut - shortcut according to the ShortcutData Module format */ - private enableShortcut(toolName: string, shortcut: string): void { + private enableShortcutForTool(toolName: string, shortcut: string): void { Shortcuts.add({ name: shortcut, on: this.api.ui.nodes.redactor, @@ -352,26 +315,12 @@ export default class Toolbox extends EventsDispatcher { * Fired when the Read-Only mode is activated */ private removeAllShortcuts(): void { - Array - .from(this.tools.values()) - .forEach((tool) => { - const shortcut = tool.shortcut; + this.toolsToBeDisplayed.forEach((tool: BlockTool) => { + const shortcut = tool.shortcut; - if (shortcut) { - Shortcuts.remove(this.api.ui.nodes.redactor, shortcut); - } - }); - } - - /** - * Creates Flipper instance to be able to leaf tools - */ - private enableFlipper(): void { - const tools = Array.from(this.nodes.toolbox.childNodes) as HTMLElement[]; - - this.flipper = new Flipper({ - items: tools, - focusedItemClass: Toolbox.CSS.toolboxButtonActive, + if (shortcut) { + Shortcuts.remove(this.api.ui.nodes.redactor, shortcut); + } }); } diff --git a/src/components/utils.ts b/src/components/utils.ts index e1e756f6..ab824308 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -762,3 +762,10 @@ export function cacheable void; +} + +/** + * Event that can be triggered by the Popover + */ +export enum PopoverEvent { + /** + * When popover overlay is clicked + */ + OverlayClicked = 'overlay-clicked', +} + +/** + * Popover is the UI element for displaying vertical lists + */ +export default class Popover extends EventsDispatcher { + /** + * Items list to be displayed + */ + private readonly items: PopoverItem[]; + + /** + * Created nodes + */ + private nodes: { + wrapper: HTMLElement; + popover: HTMLElement; + items: HTMLElement; + nothingFound: HTMLElement; + overlay: HTMLElement; + } = { + wrapper: null, + popover: null, + items: null, + nothingFound: null, + overlay: null, + } + + /** + * Additional wrapper's class name + */ + private readonly className: string; + + /** + * Listeners util instance + */ + private listeners: Listeners; + + /** + * Flipper - module for keyboard iteration between elements + */ + private flipper: Flipper; + + /** + * Pass true to enable local search field + */ + private readonly searchable: boolean; + + /** + * Instance of the Search Input + */ + private search: SearchInput; + + /** + * Label for the 'Filter' placeholder + */ + private readonly filterLabel: string; + + /** + * Label for the 'Nothing found' message + */ + private readonly nothingFoundLabel: string; + + /** + * Style classes + */ + private static get CSS(): { + popover: string; + popoverOpened: string; + itemsWrapper: string; + item: string; + itemHidden: string; + itemFocused: string; + itemLabel: string; + itemIcon: string; + itemSecondaryLabel: string; + noFoundMessage: string; + noFoundMessageShown: string; + popoverOverlay: string; + popoverOverlayHidden: string; + documentScrollLocked: string; + } { + return { + popover: 'ce-popover', + popoverOpened: 'ce-popover--opened', + itemsWrapper: 'ce-popover__items', + item: 'ce-popover__item', + itemHidden: 'ce-popover__item--hidden', + itemFocused: 'ce-popover__item--focused', + itemLabel: 'ce-popover__item-label', + itemIcon: 'ce-popover__item-icon', + itemSecondaryLabel: 'ce-popover__item-secondary-label', + noFoundMessage: 'ce-popover__no-found', + noFoundMessageShown: 'ce-popover__no-found--shown', + popoverOverlay: 'ce-popover__overlay', + popoverOverlayHidden: 'ce-popover__overlay--hidden', + documentScrollLocked: 'ce-scroll-locked', + }; + } + + /** + * Creates the Popover + * + * @param options - config + * @param options.items - config for items to be displayed + * @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 + */ + constructor({ items, className, searchable, filterLabel, nothingFoundLabel }: { + items: PopoverItem[]; + className?: string; + searchable?: boolean; + filterLabel: string; + nothingFoundLabel: string; + }) { + super(); + this.items = items; + this.className = className || ''; + this.searchable = searchable; + this.listeners = new Listeners(); + + this.filterLabel = filterLabel; + this.nothingFoundLabel = nothingFoundLabel; + + this.render(); + this.enableFlipper(); + } + + /** + * Returns rendered wrapper + */ + public getElement(): HTMLElement { + return this.nodes.wrapper; + } + + /** + * Shows the Popover + */ + public show(): void { + this.nodes.popover.classList.add(Popover.CSS.popoverOpened); + this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden); + this.flipper.activate(); + + if (this.searchable) { + window.requestAnimationFrame(() => { + this.search.focus(); + }); + } + + if (isMobileScreen()) { + document.documentElement.classList.add(Popover.CSS.documentScrollLocked); + } + } + + /** + * Hides the Popover + */ + public hide(): void { + this.search.clear(); + this.nodes.popover.classList.remove(Popover.CSS.popoverOpened); + this.nodes.overlay.classList.add(Popover.CSS.popoverOverlayHidden); + this.flipper.deactivate(); + + if (isMobileScreen()) { + document.documentElement.classList.remove(Popover.CSS.documentScrollLocked); + } + } + + /** + * Clears memory + */ + public destroy(): void { + this.listeners.removeAll(); + } + + /** + * Returns true if some item is focused + */ + public hasFocus(): boolean { + return this.flipper.hasFocus(); + } + + /** + * Helps to calculate height of popover while it is not displayed on screen. + * Renders invisible clone of popover to get actual height. + */ + @cacheable + public calculateHeight(): number { + let height = 0; + const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement; + + popoverClone.style.visibility = 'hidden'; + popoverClone.style.position = 'absolute'; + popoverClone.style.top = '-1000px'; + popoverClone.classList.add(Popover.CSS.popoverOpened); + document.body.appendChild(popoverClone); + height = popoverClone.offsetHeight; + popoverClone.remove(); + + return height; + } + + /** + * Makes the UI + */ + private render(): void { + this.nodes.wrapper = Dom.make('div', this.className); + this.nodes.popover = Dom.make('div', Popover.CSS.popover); + this.nodes.wrapper.appendChild(this.nodes.popover); + + this.nodes.overlay = Dom.make('div', [Popover.CSS.popoverOverlay, Popover.CSS.popoverOverlayHidden]); + this.nodes.wrapper.appendChild(this.nodes.overlay); + + if (this.searchable) { + this.addSearch(this.nodes.popover); + } + + this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper); + this.items.forEach(item => { + this.nodes.items.appendChild(this.createItem(item)); + }); + + this.nodes.popover.appendChild(this.nodes.items); + this.nodes.nothingFound = Dom.make('div', [ Popover.CSS.noFoundMessage ], { + textContent: this.nothingFoundLabel, + }); + + this.nodes.popover.appendChild(this.nodes.nothingFound); + + this.listeners.on(this.nodes.popover, 'click', (event: KeyboardEvent|MouseEvent) => { + const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement; + + if (clickedItem) { + this.itemClicked(clickedItem); + } + }); + + this.listeners.on(this.nodes.overlay, 'click', () => { + this.emit(PopoverEvent.OverlayClicked); + }); + } + + /** + * Adds the s4arch field to passed element + * + * @param holder - where to append search input + */ + private addSearch(holder: HTMLElement): void { + this.search = new SearchInput({ + items: this.items, + placeholder: this.filterLabel, + onSearch: (filteredItems): void => { + const itemsVisible = []; + + this.items.forEach((item, index) => { + const itemElement = this.nodes.items.children[index]; + + if (filteredItems.includes(item)) { + itemsVisible.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); + + /** + * Update flipper items with only visible + */ + this.flipper.deactivate(); + this.flipper.activate(itemsVisible); + this.flipper.focusFirst(); + }, + }); + + const searchField = this.search.getElement(); + + holder.appendChild(searchField); + } + + /** + * Renders the single item + * + * @param item - item data to be rendered + */ + private createItem(item: PopoverItem): HTMLElement { + const el = Dom.make('div', Popover.CSS.item); + + el.dataset.itemName = item.name; + const label = Dom.make('div', Popover.CSS.itemLabel, { + innerHTML: item.label, + }); + + if (item.icon) { + el.appendChild(Dom.make('div', Popover.CSS.itemIcon, { + innerHTML: item.icon, + })); + } + + el.appendChild(label); + + if (item.secondaryLabel) { + el.appendChild(Dom.make('div', Popover.CSS.itemSecondaryLabel, { + textContent: item.secondaryLabel, + })); + } + + return el; + } + + /** + * Item click handler + * + * @param itemEl - clicked item + */ + private itemClicked(itemEl: HTMLElement): void { + const allItems = this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`); + const itemIndex = Array.from(allItems).indexOf(itemEl); + const clickedItem = this.items[itemIndex]; + + clickedItem.onClick(clickedItem); + } + + /** + * 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, + focusedItemClass: Popover.CSS.itemFocused, + allowedKeys: [ + keyCodes.TAB, + keyCodes.UP, + keyCodes.DOWN, + keyCodes.ENTER, + ], + }); + } +} diff --git a/src/components/utils/search-input.ts b/src/components/utils/search-input.ts new file mode 100644 index 00000000..a5b8e3ae --- /dev/null +++ b/src/components/utils/search-input.ts @@ -0,0 +1,152 @@ +import Dom from '../dom'; +import Listeners from './listeners'; + +/** + * Item that could be searched + */ +interface SearchableItem { + label: string; +} + +/** + * Provides search input element and search logic + */ +export default class SearchInput { + /** + * Input wrapper element + */ + private wrapper: HTMLElement; + + /** + * Editable input itself + */ + private input: HTMLInputElement; + + /** + * The instance of the Listeners util + */ + private listeners: Listeners; + + /** + * Items for local search + */ + private items: SearchableItem[]; + + /** + * Current search query + */ + private searchQuery: string; + + /** + * Externally passed callback for the search + */ + private readonly onSearch: (items: SearchableItem[]) => void; + + /** + * Styles + */ + private static get CSS(): { + input: string; + icon: string; + wrapper: string; + } { + return { + wrapper: 'cdx-search-field', + icon: 'cdx-search-field__icon', + input: 'cdx-search-field__input', + }; + } + + /** + * @param options - available config + * @param options.items - searchable items list + * @param options.onSearch - search callback + * @param options.placeholder - input placeholder + */ + constructor({ items, onSearch, placeholder }: { + items: SearchableItem[]; + onSearch: (items: SearchableItem[]) => void; + placeholder: string; + }) { + this.listeners = new Listeners(); + this.items = items; + this.onSearch = onSearch; + + this.render(placeholder); + } + + /** + * Returns search field element + */ + public getElement(): HTMLElement { + return this.wrapper; + } + + /** + * Sets focus to the input + */ + public focus(): void { + this.input.focus(); + } + + /** + * Clears search query and results + */ + public clear(): void { + this.input.value = ''; + this.searchQuery = ''; + this.onSearch(this.foundItems); + } + + /** + * Clears memory + */ + public destroy(): void { + this.listeners.removeAll(); + } + + /** + * Creates the search field + * + * @param placeholder - input placeholder + */ + private render(placeholder: string): void { + this.wrapper = Dom.make('div', SearchInput.CSS.wrapper); + + const iconWrapper = Dom.make('div', SearchInput.CSS.icon); + const icon = Dom.svg('search', 16, 16); + + this.input = Dom.make('input', SearchInput.CSS.input, { + placeholder, + }) as HTMLInputElement; + + iconWrapper.appendChild(icon); + this.wrapper.appendChild(iconWrapper); + this.wrapper.appendChild(this.input); + + this.listeners.on(this.input, 'input', () => { + this.searchQuery = this.input.value; + + this.onSearch(this.foundItems); + }); + } + + /** + * Returns list of found items for the current search query + */ + private get foundItems(): SearchableItem[] { + return this.items.filter(item => this.checkItem(item)); + } + + /** + * Contains logic for checking whether passed item conforms the search query + * + * @param item - item to be checked + */ + private checkItem(item: SearchableItem): boolean { + const text = item.label.toLowerCase(); + const query = this.searchQuery.toLowerCase(); + + return text.includes(query); + } +} diff --git a/src/styles/animations.css b/src/styles/animations.css index fced4886..c8189902 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -117,3 +117,20 @@ transform: translateY(0); } } + +@keyframes panelShowingMobile { + from { + opacity: 0; + transform: translateY(14px) scale(0.98); + } + + 70% { + opacity: 1; + transform: translateY(-4px); + } + + to { + + transform: translateY(0); + } +} diff --git a/src/styles/block.css b/src/styles/block.css index 55bbc142..fb68133e 100644 --- a/src/styles/block.css +++ b/src/styles/block.css @@ -1,4 +1,17 @@ +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + .ce-block { + animation: fade-in 300ms ease; + animation-fill-mode: initial; + &:first-of-type { margin-top: 0; } diff --git a/src/styles/input.css b/src/styles/input.css new file mode 100644 index 00000000..1c94d8fe --- /dev/null +++ b/src/styles/input.css @@ -0,0 +1,46 @@ +.cdx-search-field { + --icon-margin-right: 10px; + + background: rgba(232,232,235,0.49); + border: 1px solid rgba(226,226,229,0.20); + border-radius: 6px; + padding: 2px; + display: grid; + grid-template-columns: auto auto 1fr; + grid-template-rows: auto; + + &__icon { + width: var(--toolbox-buttons-size); + height: var(--toolbox-buttons-size); + display: flex; + align-items: center; + justify-content: center; + margin-right: var(--icon-margin-right); + + .icon { + width: 14px; + height: 14px; + color: var(--grayText); + flex-shrink: 0; + } + } + + + &__input { + font-size: 14px; + outline: none; + font-weight: 500; + font-family: inherit; + border: 0; + background: transparent; + margin: 0; + padding: 0; + line-height: 22px; + min-width: calc(100% - var(--toolbox-buttons-size) - var(--icon-margin-right)); + + &::placeholder { + color: var(--grayText); + font-weight: 500; + } + } +} diff --git a/src/styles/main.css b/src/styles/main.css index 94fa26bd..e1adc48d 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -10,3 +10,5 @@ @import './export.css'; @import './stub.css'; @import './rtl.css'; +@import './popover.css'; +@import './input.css'; diff --git a/src/styles/popover.css b/src/styles/popover.css new file mode 100644 index 00000000..207907a6 --- /dev/null +++ b/src/styles/popover.css @@ -0,0 +1,142 @@ +.ce-popover { + position: absolute; + opacity: 0; + will-change: opacity, transform; + display: flex; + flex-direction: column; + padding: 6px; + min-width: 200px; + overflow: hidden; + box-sizing: border-box; + flex-shrink: 0; + max-height: 0; + + @apply --overlay-pane; + + z-index: 4; + flex-wrap: nowrap; + + &--opened { + opacity: 1; + max-height: 270px; + animation: panelShowing 100ms ease; + + @media (--mobile) { + animation: panelShowingMobile 250ms ease; + } + } + + &::-webkit-scrollbar { + width: 7px; + } + + &::-webkit-scrollbar-thumb { + box-sizing: border-box; + box-shadow: inset 0 0 2px 2px var(--bg-light); + border: 3px solid transparent; + border-left-width: 0px; + border-top-width: 4px; + border-bottom-width: 4px; + } + + @media (--mobile) { + position: fixed; + max-width: none; + min-width: auto; + left: 5px; + right: 5px; + bottom: calc(5px + env(safe-area-inset-bottom)); + top: auto; + border-radius: 10px; + } + + &__items { + overflow-y: auto; + overscroll-behavior: contain; + + @media (--not-mobile) { + margin-top: 5px; + } + } + + &__item { + @apply --popover-button; + + &--focused { + @apply --button-focused; + } + + &--hidden { + display: none; + } + + &-icon { + @apply --tool-icon; + } + + &-label { + &::after { + content: ''; + width: 25px; + display: inline-block; + } + } + + &-secondary-label { + color: var(--grayText); + font-size: 12px; + margin-left: auto; + white-space: nowrap; + letter-spacing: -0.1em; + padding-right: 5px; + margin-bottom: -2px; + opacity: 0.6; + + @media (--mobile){ + display: none; + } + } + } + + &__no-found { + @apply --popover-button; + + color: var(--grayText); + display: none; + cursor: default; + + &--shown { + display: block; + } + + &:hover { + background-color: transparent; + } + } + + @media (--mobile) { + &__overlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: var(--color-dark); + opacity: 0.5; + z-index: 3; + transition: opacity 0.12s ease-in; + will-change: opacity; + visibility: visible; + } + + .cdx-search-field { + display: none; + } + } + + &__overlay--hidden { + z-index: 0; + opacity: 0; + visibility: hidden; + } +} diff --git a/src/styles/settings.css b/src/styles/settings.css index c6693e85..78622a0b 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -1,7 +1,7 @@ .ce-settings { @apply --overlay-pane; - right: -1px; - top: 30px; + top: var(--toolbar-buttons-size); + left: 0; min-width: 114px; box-sizing: content-box; diff --git a/src/styles/toolbar.css b/src/styles/toolbar.css index ceceacb7..f3fef0ef 100644 --- a/src/styles/toolbar.css +++ b/src/styles/toolbar.css @@ -4,34 +4,18 @@ right: 0; top: 0; transition: opacity 100ms ease; - will-change: opacity, transform; - display: none; + will-change: opacity, top; - @media (--mobile) { - @apply --overlay-pane; - padding: 3px; - margin-top: 5px; - } + display: none; &--opened { display: block; - - @media (--mobile){ - display: flex; - } } &__content { max-width: var(--content-width); margin: 0 auto; position: relative; - - @media (--mobile){ - display: flex; - align-content: center; - margin: 0; - max-width: 100%; - } } &__plus { @@ -44,14 +28,9 @@ margin-top: 5px; } - &--hidden { - display: none; - } - @media (--mobile){ - display: inline-flex !important; + @apply --overlay-pane; position: static; - transform: none !important; } } @@ -64,37 +43,37 @@ right: 100%; opacity: 0; display: flex; - - @media (--mobile){ - position: absolute; - right: auto; - top: 50%; - transform: translateY(-50%); - display: flex; - align-items: center; - } + padding-right: 5px; &--opened { opacity: 1; } - &-buttons { - text-align: right; + @media (--mobile){ + right: auto; } } &__settings-btn { @apply --toolbox-button; - width: 18px; - margin: 0 5px; + margin-left: 5px; cursor: pointer; user-select: none; - } -} -.codex-editor--toolbox-opened .ce-toolbar__actions { - display: none; + @media (--not-mobile){ + width: 18px; + } + + &--hidden { + display: none; + } + + @media (--mobile){ + @apply --overlay-pane; + position: static; + } + } } /** diff --git a/src/styles/toolbox.css b/src/styles/toolbox.css index c02c5937..d8b60210 100644 --- a/src/styles/toolbox.css +++ b/src/styles/toolbox.css @@ -1,44 +1,24 @@ .ce-toolbox { - position: absolute; - visibility: hidden; - transition: opacity 100ms ease; - will-change: opacity; - display: flex; - flex-direction: row; + --gap: 8px; - @media (--mobile){ - position: static; - transform: none !important; - align-items: center; - overflow-x: auto; - } + @media (--not-mobile){ + position: absolute; + top: calc(var(--toolbox-buttons-size) + var(--gap)); + left: 0; - &--opened { - opacity: 1; - visibility: visible; - } - - &__button { - @apply --toolbox-button; - flex-shrink: 0; - margin-left: 5px; + &--opened-top { + top: calc(-1 * (var(--gap) + var(--popover-height))); + } } } -.ce-toolbox-button-tooltip { - &__shortcut { - opacity: 0.6; - word-spacing: -3px; - margin-top: 3px; - } -} - -/** - * Styles for Narrow mode - */ .codex-editor--narrow .ce-toolbox { - @media (--not-mobile) { - background: #fff; - z-index: 2; + @media (--not-mobile){ + left: auto; + right: 0; + + .ce-popover { + right: 0; + } } } diff --git a/src/styles/ui.css b/src/styles/ui.css index 4a240994..da65fc37 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -127,3 +127,12 @@ transform: rotate(360deg); } } + +.ce-scroll-locked, .ce-scroll-locked > body { + height: 100vh; + overflow: hidden; + /** + * Mobile Safari fix + */ + position: relative; +} \ No newline at end of file diff --git a/src/styles/variables.css b/src/styles/variables.css index d8528ea6..ca9b14b3 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -1,5 +1,9 @@ +/** + * Updating values in media queries should also include changes in utils.ts@isMobile + */ @custom-media --mobile (width <= 650px); @custom-media --not-mobile (width >= 651px); +@custom-media --can-hover (hover: hover); :root { /** @@ -21,7 +25,7 @@ /** * Gray icons hover */ - --color-dark: #1D202B; + --color-dark: #1D202B; /** * Blue icons @@ -95,6 +99,11 @@ } }; + --button-focused: { + box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08); + background: rgba(34, 186, 255, 0.08) !important; + }; + /** * Styles for Toolbox Buttons and Plus Button */ @@ -103,22 +112,25 @@ cursor: pointer; width: var(--toolbox-buttons-size); height: var(--toolbox-buttons-size); - border-radius: 3px; + border-radius: 7px; display: inline-flex; justify-content: center; align-items: center; + user-select: none; @media (--mobile){ width: var(--toolbox-buttons-size--mobile); height: var(--toolbox-buttons-size--mobile); } - &:hover, - &--active { - background-color: var(--bg-light); + @media (--can-hover) { + &:hover { + background-color: var(--bg-light); + } } - &--active{ + &--active { + background-color: var(--bg-light); animation: bounceIn 0.75s 1; animation-fill-mode: forwards; } @@ -132,9 +144,9 @@ display: inline-flex; align-items: center; justify-content: center; - width: 34px; - height: 34px; - line-height: 34px; + width: var(--toolbar-buttons-size); + height: var(--toolbar-buttons-size); + line-height: var(--toolbar-buttons-size); padding: 0 !important; text-align: center; border-radius: 3px; @@ -155,8 +167,7 @@ } &--focused { - box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08); - background: rgba(34, 186, 255, 0.08) !important; + @apply --button-focused; &-animated { animation-name: buttonClicked; @@ -164,5 +175,66 @@ } } }; + + /** + * Element of the Toolbox. Has icon and label + */ + --popover-button: { + display: grid; + grid-template-columns: auto auto 1fr; + grid-template-rows: auto; + justify-content: start; + white-space: nowrap; + padding: 3px; + font-size: 14px; + line-height: 20px; + font-weight: 500; + cursor: pointer; + align-items: center; + border-radius: 6px; + + &:not(:last-of-type){ + margin-bottom: 1px; + } + + @media (--can-hover) { + &:hover { + background-color: var(--bg-light); + } + } + + @media (--mobile) { + font-size: 16px; + padding: 4px; + } + }; + + /** + * Tool icon with border + */ + --tool-icon: { + display: inline-flex; + width: var(--toolbox-buttons-size); + height: var(--toolbox-buttons-size); + border: 1px solid var(--color-gray-border); + border-radius: 5px; + align-items: center; + justify-content: center; + background: #fff; + box-sizing: border-box; + flex-shrink: 0; + margin-right: 10px; + + @media (--mobile) { + width: var(--toolbox-buttons-size--mobile); + height: var(--toolbox-buttons-size--mobile); + border-radius: 8px; + } + + svg { + width: 12px; + height: 12px; + } + } } diff --git a/test/cypress/tests/block-ids.spec.ts b/test/cypress/tests/block-ids.spec.ts index 3fc1e209..e207e58f 100644 --- a/test/cypress/tests/block-ids.spec.ts +++ b/test/cypress/tests/block-ids.spec.ts @@ -31,7 +31,7 @@ describe.only('Block ids', () => { .click(); cy.get('[data-cy=editorjs]') - .get('li.ce-toolbox__button[data-tool=header]') + .get('div.ce-popover__item[data-item-name=header]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/onchange.spec.ts b/test/cypress/tests/onchange.spec.ts index 428a4ad0..f4740c30 100644 --- a/test/cypress/tests/onchange.spec.ts +++ b/test/cypress/tests/onchange.spec.ts @@ -131,7 +131,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('li.ce-toolbox__button[data-tool=delimiter]') + .get('div.ce-popover__item[data-item-name=delimiter]') .click(); cy.get('@onChange').should('be.calledThrice'); @@ -178,7 +178,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('li.ce-toolbox__button[data-tool=header]') + .get('div.ce-popover__item[data-item-name=header]') .click(); cy.get('@onChange').should('be.calledTwice'); @@ -245,6 +245,14 @@ describe('onChange callback', () => { it('should fire onChange callback when block is removed', () => { createEditor(); + /** + * The only block does not have Tune menu, so need to create at least 2 blocks to test deleting + */ + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .type('some text'); + cy.get('[data-cy=editorjs]') .get('div.ce-block') .click();