diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index c93c90f9c..b1ff985dd 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 b07a67227..0496fbbc1 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 000000000..1485338be
--- /dev/null
+++ b/src/assets/search.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/flipper.ts b/src/components/flipper.ts
index a97216a61..7ab00fbb7 100644
--- a/src/components/flipper.ts
+++ b/src/components/flipper.ts
@@ -18,20 +18,22 @@ 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)
+ * Optional callback for button click
*/
- allowArrows?: boolean;
+ activateCallback?: (item: HTMLElement) => void;
/**
- * Optional callback for button click
+ * 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
*/
- activateCallback?: (item: HTMLElement) => void;
+ 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 f02cac617..42a1520b5 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 f75f3019e..be5a16a5e 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 864ec85b6..df48ad3d2 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 91ded10dd..799b60fd5 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 aecd6de57..75bfef905 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 cfa9af748..42b86a050 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 2c813caf1..89da4215e 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
- .from(this.tools.values())
- .forEach((tool) => this.addTool(tool));
+ 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;
}
/**
- * Append Tool to the Toolbox
- *
- * @param {BlockToolConstructable} tool - BlockTool object
+ * Handles overlay click
*/
- private addTool(tool: BlockTool): void {
- const toolToolboxSettings = tool.toolbox;
-
- /**
- * 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);
- });
-
- /**
- * 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++;
+ private onOverlayClicked = (): void => {
+ this.close();
}
/**
- * Draw tooltip for toolbox tools
- *
- * @param tool - BlockTool object
- * @returns {HTMLElement}
+ * Returns list of tools that enables the Toolbox (by specifying the 'toolbox' getter)
*/
- private drawTooltip(tool: BlockTool): HTMLElement {
- const toolboxSettings = tool.toolbox || {};
- const name = I18n.t(I18nInternalNS.toolNames, toolboxSettings.title || tool.name);
-
- let shortcut = tool.shortcut;
+ @_.cacheable
+ private get toolsToBeDisplayed(): BlockTool[] {
+ return Array
+ .from(this.tools.values())
+ .filter(tool => {
+ const toolToolboxSettings = tool.toolbox;
+
+ /**
+ * Skip tools that don't pass 'toolbox' property
+ */
+ if (!toolToolboxSettings) {
+ return false;
+ }
- const tooltip = $.make('div', Toolbox.CSS.buttonTooltip);
- const hint = document.createTextNode(_.capitalize(name));
+ if (toolToolboxSettings && !toolToolboxSettings.icon) {
+ _.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name);
- tooltip.appendChild(hint);
+ return false;
+ }
- if (shortcut) {
- shortcut = _.beautifyShortcut(shortcut);
+ return true;
+ });
+ }
- tooltip.appendChild($.make('div', Toolbox.CSS.buttonShortcut, {
- textContent: shortcut,
- }));
- }
+ /**
+ * Iterate all tools and enable theirs shortcuts if specified
+ */
+ private enableShortcuts(): void {
+ this.toolsToBeDisplayed.forEach((tool: BlockTool) => {
+ const shortcut = tool.shortcut;
- return tooltip;
+ if (shortcut) {
+ this.enableShortcutForTool(tool.name, shortcut);
+ }
+ });
}
/**
@@ -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;
-
- 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.toolsToBeDisplayed.forEach((tool: BlockTool) => {
+ const shortcut = tool.shortcut;
- 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 e1e756f65..ab8243084 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 000000000..a5b8e3aef
--- /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 fced4886d..c81899025 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 55bbc142b..fb68133e4 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 000000000..1c94d8fe4
--- /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 94fa26bd1..e1adc48d4 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 000000000..207907a60
--- /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 c6693e852..78622a0bf 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 ceceacb79..f3fef0ef8 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 c02c59379..d8b602100 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;
- }
-
- &--opened {
- opacity: 1;
- visibility: visible;
- }
+ @media (--not-mobile){
+ position: absolute;
+ top: calc(var(--toolbox-buttons-size) + var(--gap));
+ left: 0;
- &__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 4a240994b..da65fc376 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 d8528ea6e..ca9b14b36 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 3fc1e2090..e207e58fb 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 428a4ad07..f4740c308 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();