the popover component, vertical toolbox

This commit is contained in:
Peter Savchenko 2022-01-13 20:45:53 +03:00
parent ff91466b14
commit 11c040513b
No known key found for this signature in database
GPG key ID: E68306B1AB0F727C
13 changed files with 501 additions and 239 deletions

View file

@ -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;
}
/**
@ -266,4 +266,15 @@ export default class Flipper {
event.preventDefault();
event.stopPropagation();
}
/**
* Fired after flipping in any direction
*/
private flipCallback(): void {
if (this.iterator.currentItem) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
this.iterator.currentItem.scrollIntoViewIfNeeded();
}
}
}

View file

@ -309,6 +309,10 @@ export default class BlockManager extends Module {
});
}
/**
* @todo emit beforeInsert
*/
this._blocks.insert(newIndex, block, replace);
/**

View file

@ -182,7 +182,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
close: () => void;
open: () => void;
toggle: () => void;
flipperHasFocus: boolean;
hasFocus: () => boolean;
} {
return {
opened: this.toolboxInstance.opened,
@ -196,7 +196,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.toolboxInstance.open();
},
toggle: (): void => this.toolboxInstance.toggle(),
flipperHasFocus: this.toolboxInstance.flipperHasFocus,
hasFocus: (): boolean => this.toolboxInstance.hasFocus(),
};
}

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

View file

@ -96,3 +96,40 @@ if (!Element.prototype.prepend) {
this.insertBefore(docFrag, this.firstChild);
};
}
/**
* ScrollIntoViewIfNeeded polyfill by KilianSSL (forked from hsablonniere)
*
* @see {@link https://gist.github.com/KilianSSL/774297b76378566588f02538631c3137}
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
if (!Element.prototype.scrollIntoViewIfNeeded) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
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,20 @@
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 from '../utils/popover';
/**
* @todo check small tools number there should not be a scroll
* @todo add search in popover
* @todo hide toolbar after some toolbox item clicked (and the new block inserted)
* @todo do not show Block Tunes Toggler near only-one block
* @todo Plus Button should be appeared near all blocks (even non-empty)
* @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
@ -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,6 +65,11 @@ 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
*/
@ -70,11 +80,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
*/
private nodes: {
toolbox: HTMLElement;
buttons: HTMLElement[];
} = {
toolbox: null,
buttons: [],
}
};
/**
* CSS styles
@ -84,34 +92,9 @@ 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',
};
}
/**
* 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()
*/
@ -129,62 +112,65 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
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;
}
/**
* Makes the Toolbox
*/
public make(): Element {
this.nodes.toolbox = $.make('div', Toolbox.CSS.toolbox);
this.popover = new Popover({
className: Toolbox.CSS.toolbox,
items: this.toolsToBeDisplayed.map(tool => {
return {
icon: tool.toolbox.icon,
label: tool.toolbox.title,
onClick: (item): void => {
this.toolButtonActivated(tool.name);
},
secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
};
}),
});
this.addTools();
this.enableFlipper();
/**
* 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();
}
/**
* 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 +182,20 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
return;
}
this.emit(ToolboxEvent.Opened);
this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpened);
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.emit(ToolboxEvent.Closed);
}
/**
@ -228,106 +210,43 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
}
/**
* Iterates available tools and appends them to the Toolbox
* Returns list of tools that enables the Toolbox (by specifying the 'toolbox' getter)
*/
private addTools(): void {
Array
@_.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 +255,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 +271,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

@ -0,0 +1,212 @@
import Dom from '../dom';
import Listeners from './listeners';
import Flipper from '../flipper';
/**
* 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;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* Itm click handler
*
* @param item - clicked item
*/
onClick: (item: PopoverItem) => void;
}
/**
* Popover is the UI element for displaying vertical lists
*/
export default class Popover {
/**
* Items list to be displayed
*/
private readonly items: PopoverItem[];
/**
* Created nodes
*/
private nodes: {
wrapper: HTMLElement;
} = {
wrapper: 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;
/**
* Style classes
*/
private static get CSS(): {
popover: string;
popoverOpened: string;
item: string;
itemFocused: string;
itemLabel: string;
itemIcon: string;
itemSecondaryLabel: string;
} {
return {
popover: 'ce-popover',
popoverOpened: 'ce-popover--opened',
item: 'ce-popover__item',
itemFocused: 'ce-popover__item--focused',
itemLabel: 'ce-popover__item-label',
itemIcon: 'ce-popover__item-icon',
itemSecondaryLabel: 'ce-popover__item-secondary-label',
};
}
/**
* 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
*/
constructor({ items, className }: {items: PopoverItem[]; className?: string}) {
this.items = items;
this.className = className || '';
this.listeners = new Listeners();
this.render();
this.enableFlipper();
}
/**
* Returns rendered wrapper
*/
public getElement(): HTMLElement {
return this.nodes.wrapper;
}
/**
* Shows the Popover
*/
public show(): void {
this.nodes.wrapper.classList.add(Popover.CSS.popoverOpened);
this.flipper.activate();
}
/**
* Hides the Popover
*/
public hide(): void {
this.nodes.wrapper.classList.remove(Popover.CSS.popoverOpened);
this.flipper.deactivate();
}
/**
* Clears memory
*/
public destroy(): void {
this.listeners.removeAll();
}
/**
* Returns true if some item is focused
*/
public hasFocus(): boolean {
return this.flipper.hasFocus();
}
/**
* Makes the UI
*/
private render(): void {
this.nodes.wrapper = Dom.make('div', [Popover.CSS.popover, this.className]);
this.items.forEach(item => {
this.nodes.wrapper.appendChild(this.createItem(item));
});
this.listeners.on(this.nodes.wrapper, 'click', (event: KeyboardEvent|MouseEvent) => {
const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement;
if (clickedItem) {
this.itemClicked(clickedItem);
}
});
}
/**
* Renders the single item
*
* @param item - item data to be rendered
*/
private createItem(item: PopoverItem): HTMLElement {
const el = Dom.make('div', Popover.CSS.item);
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, {
innerHTML: 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,
});
}
}

View file

@ -1,4 +1,16 @@
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.ce-block {
animation: fade-in 300ms ease forwards;
&:first-of-type {
margin-top: 0;
}

View file

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

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

@ -0,0 +1,74 @@
.ce-popover {
position: absolute;
visibility: hidden;
transition: opacity 100ms ease;
will-change: opacity;
display: flex;
flex-direction: column;
padding: 4px;
min-width: 180px;
max-height: 284px;
overflow-y: auto;
box-sizing: border-box;
flex-shrink: 0;
overscroll-behavior: contain;
@apply --overlay-pane;
flex-wrap: nowrap;
&--opened {
visibility: visible;
}
&::-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;
}
&__item {
@apply --popover-button;
flex-basis: 100%;
flex-shrink: 0;
&--focused {
@apply --button-focused;
}
&-icon {
@apply --tool-icon;
flex-shrink: 0;
}
&-label {
flex-shrink: 0;
&::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;
flex-shrink: 0;
opacity: 0.6;
}
}
}

View file

@ -1,44 +1,2 @@
.ce-toolbox {
position: absolute;
visibility: hidden;
transition: opacity 100ms ease;
will-change: opacity;
display: flex;
flex-direction: row;
@media (--mobile){
position: static;
transform: none !important;
align-items: center;
overflow-x: auto;
}
&--opened {
opacity: 1;
visibility: visible;
}
&__button {
@apply --toolbox-button;
flex-shrink: 0;
margin-left: 5px;
}
}
.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;
}
}

View file

@ -21,7 +21,7 @@
/**
* Gray icons hover
*/
--color-dark: #1D202B;
--color-dark: #1D202B;
/**
* Blue icons
@ -95,6 +95,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,7 +108,7 @@
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;
@ -155,8 +160,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 +168,49 @@
}
}
};
/**
* Element of the Toolbox. Has icon and label
*/
--popover-button: {
display: flex;
padding: 4px;
font-size: 14px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
align-items: center;
border-radius: 7px;
&:not(:last-of-type){
margin-bottom: 1px;
}
&:hover {
background-color: var(--bg-light);
}
};
/**
* Tool icon with border
*/
--tool-icon: {
display: inline-flex;
width: 26px;
height: 26px;
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;
svg {
width: 12px;
height: 12px;
}
}
}

@ -1 +1 @@
Subproject commit 4b193c33c3efe00ffc13b16839cffb5e339df526
Subproject commit 21cbdea6e5e61094b046f47e8cb423a817cec3ed