diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 42686d9bc..73d1786ba 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -181,7 +181,10 @@ export default class Toolbar extends Module { } { 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. @@ -279,7 +282,7 @@ 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 @@ -506,6 +509,15 @@ export default class Toolbar extends Module { * Subscribe to the 'block-hovered' event */ this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => { + /** + * Do not move Toolbar by hover on mobile view + * + * @see https://github.com/codex-team/editor.js/issues/1972 + */ + if (_.isMobile()) { + return; + } + /** * Do not move toolbar if Block Settings or Toolbox opened */ diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index bcec19e98..47d14d564 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -5,7 +5,7 @@ import BlockTool from '../tools/block'; import ToolsCollection from '../tools/collection'; import { API } from '../../../types'; import EventsDispatcher from '../utils/events'; -import Popover from '../utils/popover'; +import Popover, { PopoverEvent } from '../utils/popover'; /** * @todo check small tools number — there should not be a scroll @@ -136,6 +136,7 @@ export default class Toolbox extends EventsDispatcher { return { icon: tool.toolbox.icon, label: tool.toolbox.title, + name: tool.name, onClick: (item): void => { this.toolButtonActivated(tool.name); }, @@ -144,6 +145,10 @@ export default class Toolbox extends EventsDispatcher { }), }); + this.popover.on(PopoverEvent.OverlayClicked, () => { + this.close(); + }); + /** * Enable tools shortcuts */ diff --git a/src/components/utils.ts b/src/components/utils.ts index e1e756f65..fda96c9a7 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 { +export default class Popover extends EventsDispatcher { /** * Items list to be displayed */ @@ -44,12 +62,16 @@ export default class Popover { */ private nodes: { wrapper: HTMLElement; + popover: HTMLElement; items: HTMLElement; nothingFound: HTMLElement; + overlay: HTMLElement; } = { wrapper: null, + popover: null, items: null, nothingFound: null, + overlay: null, } /** @@ -90,6 +112,9 @@ export default class Popover { itemSecondaryLabel: string; noFoundMessage: string; noFoundMessageShown: string; + popoverOverlay: string; + popoverOverlayHidden: string; + documentScrollLocked: string; } { return { popover: 'ce-popover', @@ -103,6 +128,9 @@ export default class Popover { 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', }; } @@ -122,6 +150,7 @@ export default class Popover { filterLabel: string; nothingFoundLabel: string; }) { + super(); this.items = items; this.className = className || ''; this.searchable = searchable; @@ -145,7 +174,8 @@ export default class Popover { * Shows the Popover */ public show(): void { - this.nodes.wrapper.classList.add(Popover.CSS.popoverOpened); + this.nodes.popover.classList.add(Popover.CSS.popoverOpened); + this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden); this.flipper.activate(); if (this.searchable) { @@ -153,14 +183,23 @@ export default class Popover { this.search.focus(); }); } + + if (isMobile()) { + document.documentElement.classList.add(Popover.CSS.documentScrollLocked); + } } /** * Hides the Popover */ public hide(): void { - this.nodes.wrapper.classList.remove(Popover.CSS.popoverOpened); + this.nodes.popover.classList.remove(Popover.CSS.popoverOpened); + this.nodes.overlay.classList.add(Popover.CSS.popoverOverlayHidden); this.flipper.deactivate(); + + if (isMobile()) { + document.documentElement.classList.remove(Popover.CSS.documentScrollLocked); + } } /** @@ -181,32 +220,40 @@ export default class Popover { * Makes the UI */ private render(): void { - this.nodes.wrapper = Dom.make('div', [Popover.CSS.popover, this.className]); + 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.wrapper); + 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.wrapper.appendChild(this.nodes.items); - this.nodes.nothingFound = Dom.make('div', [Popover.CSS.noFoundMessage], { + this.nodes.popover.appendChild(this.nodes.items); + this.nodes.nothingFound = Dom.make('div', [ Popover.CSS.noFoundMessage ], { textContent: this.nothingFoundLabel, }); - this.nodes.wrapper.appendChild(this.nodes.nothingFound); + this.nodes.popover.appendChild(this.nodes.nothingFound); - this.listeners.on(this.nodes.wrapper, 'click', (event: KeyboardEvent|MouseEvent) => { + 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); + }); } /** @@ -255,6 +302,8 @@ export default class Popover { */ private createItem(item: PopoverItem): HTMLElement { const el = Dom.make('div', Popover.CSS.item); + + el.setAttribute('data-item-name', item.name); const label = Dom.make('div', Popover.CSS.itemLabel, { innerHTML: item.label, }); diff --git a/src/components/utils/search-input.ts b/src/components/utils/search-input.ts index 2503a8ac9..fb794ef48 100644 --- a/src/components/utils/search-input.ts +++ b/src/components/utils/search-input.ts @@ -1,6 +1,6 @@ import Dom from '../dom'; import Listeners from './listeners'; -import $ from "../dom"; +import $ from '../dom'; /** * Item that could be searched @@ -13,7 +13,6 @@ interface SearchableItem { * Provides search input element and search logic */ export default class SearchInput { - private wrapper: HTMLElement; private input: HTMLInputElement; private listeners: Listeners; @@ -26,10 +25,12 @@ export default class SearchInput { */ 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', }; } @@ -80,13 +81,15 @@ export default class SearchInput { */ private render(placeholder: string): void { this.wrapper = Dom.make('div', SearchInput.CSS.wrapper); + const iconWrapper = Dom.make('div', SearchInput.CSS.icon); const icon = $.svg('search', 16, 16); this.input = Dom.make('input', SearchInput.CSS.input, { placeholder, }) as HTMLInputElement; - this.wrapper.appendChild(icon); + iconWrapper.appendChild(icon); + this.wrapper.appendChild(iconWrapper); this.wrapper.appendChild(this.input); this.listeners.on(this.input, 'input', () => { 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/input.css b/src/styles/input.css index dc9adbf95..7af2e0ef5 100644 --- a/src/styles/input.css +++ b/src/styles/input.css @@ -4,19 +4,29 @@ select, button, progress { max-width: 100%; } .cdx-search-field { background: rgba(232,232,235,0.49); border: 1px solid rgba(226,226,229,0.20); - border-radius: 5px; - padding: 4px 7px; - display: flex; - align-items: center; + border-radius: 6px; + padding: 3px; + display: grid; + grid-template-columns: auto auto 1fr; + grid-template-rows: auto; - .icon { - width: 14px; - height: 14px; - margin-right: 17px; - margin-left: 2px; - color: var(--grayText); + &__icon { + width: var(--toolbox-buttons-size); + height: var(--toolbox-buttons-size); + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + + .icon { + width: 14px; + height: 14px; + color: var(--grayText); + flex-shrink: 0; + } } + &__input { font-size: 14px; outline: none; diff --git a/src/styles/popover.css b/src/styles/popover.css index 8b6992447..2516291f6 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -1,8 +1,8 @@ .ce-popover { position: absolute; + opacity: 0; visibility: hidden; - transition: opacity 100ms ease; - will-change: opacity; + will-change: opacity, transform; display: flex; flex-direction: column; padding: 4px; @@ -14,10 +14,17 @@ @apply --overlay-pane; + z-index: 4; flex-wrap: nowrap; &--opened { - visibility: visible; + opacity: 1; + visibility: visible; + animation: panelShowing 100ms ease; + + @media (--mobile) { + animation: panelShowingMobile 250ms ease; + } } &::-webkit-scrollbar { @@ -33,20 +40,24 @@ border-bottom-width: 4px; } - @media (--mobile){ + @media (--mobile) { position: fixed; max-width: none; min-width: auto; - left: 0; - right: 0; - bottom: 0; + left: 5px; + right: 5px; + bottom: calc(5px + env(safe-area-inset-bottom)); top: auto; + border-radius: 10px; } &__items { overflow-y: auto; - margin-top: 5px; overscroll-behavior: contain; + + @media (--not-mobile) { + margin-top: 5px; + } } &__item { @@ -81,6 +92,10 @@ padding-right: 5px; margin-bottom: -2px; opacity: 0.6; + + @media (--mobile){ + display: none; + } } } @@ -99,4 +114,30 @@ 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/toolbar.css b/src/styles/toolbar.css index e81872a09..a9b34fdcd 100644 --- a/src/styles/toolbar.css +++ b/src/styles/toolbar.css @@ -4,7 +4,8 @@ right: 0; top: 0; transition: opacity 100ms ease; - will-change: opacity, transform; + will-change: opacity, top; + display: none; &--opened { diff --git a/src/styles/toolbox.css b/src/styles/toolbox.css index fd340c34a..d933d8c46 100644 --- a/src/styles/toolbox.css +++ b/src/styles/toolbox.css @@ -1,4 +1,18 @@ .ce-toolbox { - top: var(--toolbar-buttons-size); - left: 0; + @media (--not-mobile){ + position: absolute; + top: var(--toolbar-buttons-size); + left: 0; + } +} + +.codex-editor--narrow .ce-toolbox { + @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 810bcfc66..6190ba6bb 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 { /** @@ -119,12 +123,14 @@ 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; } @@ -185,14 +191,20 @@ font-weight: 500; cursor: pointer; align-items: center; - border-radius: 7px; + border-radius: 6px; &:not(:last-of-type){ margin-bottom: 1px; } - &:hover { - background-color: var(--bg-light); + @media (--can-hover) { + &:hover { + background-color: var(--bg-light); + } + } + + @media (--mobile) { + font-size: 16px; } }; @@ -201,8 +213,8 @@ */ --tool-icon: { display: inline-flex; - width: 26px; - height: 26px; + width: var(--toolbox-buttons-size); + height: var(--toolbox-buttons-size); border: 1px solid var(--color-gray-border); border-radius: 5px; align-items: center; @@ -212,6 +224,12 @@ 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 3397c5a64..2c23ef8d2 100644 --- a/test/cypress/tests/onchange.spec.ts +++ b/test/cypress/tests/onchange.spec.ts @@ -104,7 +104,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'); @@ -171,6 +171,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();