feat(ui): the toolbox became vertical (#2014)

* the popover component, vertical toolbox

* toolbox position improved

* popover width improved

* always show the plus button

* search field added

* search input in popover

* trying to create mobile toolbox

* feat(toolbox): popover adapted for mobile devices (#2004)

* FIx mobile popover fixed positioning

* Add mobile popover overlay

* Hide mobile popover on scroll

* Alter toolbox buttons hover

* Fix closing popover on overlay click

* Tests fix

* Fix onchange test

* restore focus after toolbox closing by ESC

* don't move toolbar by block-hover on mobile

Resolves #1972

* popover mobile styles improved

* Cleanup

* Remove scroll event listener

* Lock scroll on mobile

* don't show shortcuts in mobile popover

* Change data attr name

* Remove unused styles

* Remove unused listeners

* disable hover on mobile popover

* Scroll fix

* Lint

* Revert "Scroll fix"

This reverts commit 82deae543e.

* Return back background color for active state of toolbox buttons

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>

* Vertical toolbox fixes (#2017)

* Replace visibility property with display for hiding popover

* Disable arrow right and left keys for popover

* Revert "Replace visibility property with display for hiding popover"

This reverts commit af521cf6f2.

* Hide popover via setting max-height to 0 to fix animation in safari

* Remove redundant condition

* Extend element interface to avoid ts errors

* Do not subscribe to block hovered if mobile

* Add unsubscribing from overlay click event

* Rename isMobile to isMobileScreen

* Cleanup

* fix: popover opening direction (#2022)

* Change popover opening direction based on available space below it

* Update check

* Use cacheable decorator

* Update src/components/flipper.ts

Co-authored-by: George Berezhnoy <gohabereg@users.noreply.github.com>

* Fixes

* Fix test

* Clear search on popover hide

* Fix popover width

* Fix for tests

* Update todos

* Linter fixes

* rm todo about beforeInsert

because I have no idea what does it mean

* i18n for search labels done

* rm methods for hiding/showing of +

* some code style update

* Update CHANGELOG.md

* make the list items a little bit compact

* fix z-index issue caused by block-appearing animation

also, improve popover padding for two reasons:

- make the popover more consistent with the Table tool popover (in future, it can be done with the same api method)
- make popover looks better

Co-authored-by: Tanya Fomina <fomina.tatianaaa@yandex.ru>
Co-authored-by: George Berezhnoy <gohabereg@users.noreply.github.com>
This commit is contained in:
Peter Savchenko 2022-04-25 18:28:58 +03:00 committed by GitHub
parent f9e280fcad
commit 8f156a87ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1187 additions and 373 deletions

View file

@ -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.
- and many little improvements.

View file

@ -193,7 +193,9 @@
},
"toolbar": {
"toolbox": {
"Add": "Добавить"
"Add": "Добавить",
"Filter": "Поиск",
"Noting found": "Ничего не найдено"
}
}
},

3
src/assets/search.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M12.711 2.18a7.448 7.448 0 0 1 .79 9.603l2.143 2.144a1.214 1.214 0 1 1-1.717 1.717L11.783 13.5a7.446 7.446 0 1 1 .928-11.32ZM11.39 3.61a5.5 5.5 0 1 0-7.778 7.78 5.5 5.5 0 0 0 7.778-7.78Z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -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();
}
}
}

View file

@ -13,7 +13,9 @@
},
"toolbar": {
"toolbox": {
"Add": ""
"Add": "",
"Filter": "",
"Noting found": ""
}
}
},

View file

@ -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);
}
}
}
/**

View file

@ -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<ToolbarNodes> {
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<ToolbarNodes> {
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<ToolbarNodes> {
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<ToolbarNodes> {
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<ToolbarNodes> {
};
}
/**
* 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<ToolbarNodes> {
/**
* 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<ToolbarNodes> {
* - 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<ToolbarNodes> {
/**
* 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<ToolbarNodes> {
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<ToolbarNodes> {
}, 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);
});
}
}
/**

View file

@ -698,7 +698,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
private enableFlipper(): void {
this.flipper = new Flipper({
focusedItemClass: this.CSS.focusedButton,
allowArrows: false,
allowedKeys: [
_.keyCodes.ENTER,
_.keyCodes.TAB,
],
});
}
}

View file

@ -231,7 +231,7 @@ export default class UI extends Module<UINodes> {
* 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<UINodes> {
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<UINodes> {
*/
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;

View file

@ -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);
}
};
}

View file

@ -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<ToolboxEvent> {
* @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<ToolboxEvent> {
*/
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<BlockTool>;
/**
* Text labels used in the Toolbox. Should be passed from the i18n module
*/
private i18nLabels: Record<toolboxTextLabelsKeys, string>;
/**
* 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<ToolboxEvent> {
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<ToolboxEvent> {
* @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<BlockTool>; i18nLabels: Record<toolboxTextLabelsKeys, string>}) {
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<ToolboxEvent> {
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<ToolboxEvent> {
}
/**
* 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<ToolboxEvent> {
* @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<ToolboxEvent> {
* 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);
}
});
}

View file

@ -762,3 +762,10 @@ export function cacheable<Target, Value, Arguments extends unknown[] = unknown[]
return descriptor;
};
/**
* True if screen has mobile size
*/
export function isMobileScreen(): boolean {
return window.matchMedia('(max-width: 650px)').matches;
}

View file

@ -0,0 +1,391 @@
import Dom from '../dom';
import Listeners from './listeners';
import Flipper from '../flipper';
import SearchInput from './search-input';
import EventsDispatcher from './events';
import { isMobileScreen, keyCodes, cacheable } from '../utils';
/**
* Describe parameters for rendering the single item of Popover
*/
export interface PopoverItem {
/**
* Item icon to be appeared near a title
*/
icon: string;
/**
* Displayed text
*/
label: string;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* Itm click handler
*
* @param item - clicked item
*/
onClick: (item: PopoverItem) => void;
}
/**
* Event that can be triggered by the Popover
*/
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<PopoverEvent> {
/**
* 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,
],
});
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}

46
src/styles/input.css Normal file
View file

@ -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;
}
}
}

View file

@ -10,3 +10,5 @@
@import './export.css';
@import './stub.css';
@import './rtl.css';
@import './popover.css';
@import './input.css';

142
src/styles/popover.css Normal file
View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;
}
}
}
/**

View file

@ -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;
}
}
}

View file

@ -127,3 +127,12 @@
transform: rotate(360deg);
}
}
.ce-scroll-locked, .ce-scroll-locked > body {
height: 100vh;
overflow: hidden;
/**
* Mobile Safari fix
*/
position: relative;
}

View file

@ -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;
}
}
}

View file

@ -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]')

View file

@ -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();