feat(ui): native-like tab behaviour, slash for toolbox (#2569)

* slash to open toolbox, tab for navigation

* tab, focus improvements

- remove "focused" block state
- tab navigation respects inputs
- allow to focus contentless blocks

* fix tests

* tests for Slash

* tab tests

* test for tabbing out of editor

* tests fixed

* review fixes
This commit is contained in:
Peter Savchenko 2023-12-22 23:15:35 +03:00 committed by GitHub
parent ea2be754e7
commit cd29c52e51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 755 additions and 197 deletions

View file

@ -31,6 +31,7 @@
"ClientRect": true,
"ArrayLike": true,
"InputEvent": true,
"unknown": true
"unknown": true,
"requestAnimationFrame": true
}
}

View file

@ -6,6 +6,7 @@ export default defineConfig({
},
fixturesFolder: 'test/cypress/fixtures',
screenshotsFolder: 'test/cypress/screenshots',
video: false,
videosFolder: 'test/cypress/videos',
e2e: {
// We've imported your old cypress plugins here.

View file

@ -3,6 +3,9 @@
### 2.29.0
- `New` — Editor Config now has the `style.nonce` attribute that could be used to allowlist editor style tag for Content Security Policy "style-src"
- `New` — Toolbox now will be opened by '/' in empty Block instead of Tab
- `New` — Block Tunes now will be opened by 'CMD+/' instead of Tab in non-empty block
- `New` — Tab now will navigate through Blocks. In last block Tab will navigate to the next input on page.
- `Fix` — Passing an empty array via initial data or `blocks.render()` won't break the editor
- `Fix` — Layout did not shrink when a large document cleared in Chrome
- `Fix` — Multiple Tooltip elements creation fixed
@ -11,7 +14,9 @@
- `Fix``blocks.render()` won't lead the `onChange` call in Safari
- `Fix` — Editor wrapper element growing on the Inline Toolbar close
- `Fix` — Fix errors thrown by clicks on a document when the editor is being initialized
- `Fix` — Inline Toolbar sometimes opened in an incorrect position. Now it will be aligned by the left side of the selected text. And won't overflow the right side of the text column.
- `Improvement` — Now you can set focus via arrows/Tab to "contentless" (decorative) blocks like Delimiter which have no inputs.
- `Improvement` — Inline Toolbar sometimes opened in an incorrect position. Now it will be aligned by the left side of the selected text. And won't overflow the right side of the text column.
- `Refactoring``ce-block--focused` class toggling removed as unused.
### 2.28.2

View file

@ -54,6 +54,7 @@
"core-js": "3.30.0",
"cypress": "^12.9.0",
"cypress-intellij-reporter": "^0.0.7",
"cypress-plugin-tab": "^1.0.5",
"cypress-terminal-report": "^5.3.2",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",

View file

@ -84,6 +84,13 @@ function BlockAPI(
return block.stretched;
},
/**
* True if Block has inputs to be focused
*/
get focusable(): boolean {
return block.focusable;
},
/**
* Call Tool method with errors handler under-the-hood
*

View file

@ -111,7 +111,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
wrapper: 'ce-block',
wrapperStretched: 'ce-block--stretched',
content: 'ce-block__content',
focused: 'ce-block--focused',
selected: 'ce-block--selected',
dropTarget: 'ce-block--drop-target',
};
@ -392,13 +391,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return _.isFunction(this.toolInstance.merge);
}
/**
* If Block contains inputs, it is focusable
*/
public get focusable(): boolean {
return this.inputs.length !== 0;
}
/**
* Check block for emptiness
*
* @returns {boolean}
*/
public get isEmpty(): boolean {
const emptyText = $.isEmpty(this.pluginsContent);
const emptyText = $.isEmpty(this.pluginsContent, '/');
const emptyMedia = !this.hasMedia;
return emptyText && emptyMedia;
@ -429,22 +435,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return !!this.holder.querySelector(mediaTags.join(','));
}
/**
* Set focused state
*
* @param {boolean} state - 'true' to select, 'false' to remove selection
*/
public set focused(state: boolean) {
this.holder.classList.toggle(Block.CSS.focused, state);
}
/**
* Get Block's focused state
*/
public get focused(): boolean {
return this.holder.classList.contains(Block.CSS.focused);
}
/**
* Set selected state
* We don't need to mark Block as Selected when it is empty

View file

@ -63,7 +63,6 @@ export default class Core {
if ((this.configuration as EditorConfig).autofocus) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
BlockManager.highlightCurrentNode();
}
onReady();

View file

@ -348,9 +348,10 @@ export default class Dom {
* @description Method checks simple Node without any childs for emptiness
* If you have Node with 2 or more children id depth, you better use {@link Dom#isEmpty} method
* @param {Node} node - node to check
* @param {string} [ignoreChars] - char or substring to treat as empty
* @returns {boolean} true if it is empty
*/
public static isNodeEmpty(node: Node): boolean {
public static isNodeEmpty(node: Node, ignoreChars?: string): boolean {
let nodeText;
if (this.isSingleTag(node as HTMLElement) && !this.isLineBreakTag(node as HTMLElement)) {
@ -363,6 +364,10 @@ export default class Dom {
nodeText = node.textContent.replace('\u200B', '');
}
if (ignoreChars) {
nodeText = nodeText.replace(new RegExp(ignoreChars, 'g'), '');
}
return nodeText.trim().length === 0;
}
@ -386,9 +391,10 @@ export default class Dom {
*
* @description Pushes to stack all DOM leafs and checks for emptiness
* @param {Node} node - node to check
* @param {string} [ignoreChars] - char or substring to treat as empty
* @returns {boolean}
*/
public static isEmpty(node: Node): boolean {
public static isEmpty(node: Node, ignoreChars?: string): boolean {
/**
* Normalize node to merge several text nodes to one to reduce tree walker iterations
*/
@ -403,7 +409,7 @@ export default class Dom {
continue;
}
if (this.isLeaf(node) && !this.isNodeEmpty(node)) {
if (this.isLeaf(node) && !this.isNodeEmpty(node, ignoreChars)) {
return false;
}

View file

@ -52,6 +52,13 @@ export default class BlockEvents extends Module {
case _.keyCodes.TAB:
this.tabPressed(event);
break;
case _.keyCodes.SLASH:
if (event.ctrlKey || event.metaKey) {
this.commandSlashPressed();
} else {
this.slashPressed();
}
break;
}
}
@ -86,7 +93,6 @@ export default class BlockEvents extends Module {
const isShortcut = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
if (!isShortcut) {
this.Editor.BlockManager.clearFocused();
this.Editor.BlockSelection.clearSelection(event);
}
}
@ -113,40 +119,6 @@ export default class BlockEvents extends Module {
this.Editor.UI.checkEmptiness();
}
/**
* Open Toolbox to leaf Tools
*
* @param {KeyboardEvent} event - tab keydown event
*/
public tabPressed(event: KeyboardEvent): void {
/**
* Clear blocks selection by tab
*/
this.Editor.BlockSelection.clearSelection(event);
const { BlockManager, InlineToolbar, ConversionToolbar } = this.Editor;
const currentBlock = BlockManager.currentBlock;
if (!currentBlock) {
return;
}
const isEmptyBlock = currentBlock.isEmpty;
const canOpenToolbox = currentBlock.tool.isDefault && isEmptyBlock;
const conversionToolbarOpened = !isEmptyBlock && ConversionToolbar.opened;
const inlineToolbarOpened = !isEmptyBlock && !SelectionUtils.isCollapsed && InlineToolbar.opened;
const canOpenBlockTunes = !conversionToolbarOpened && !inlineToolbarOpened;
/**
* For empty Blocks we show Plus button via Toolbox only for default Blocks
*/
if (canOpenToolbox) {
this.activateToolbox();
} else if (canOpenBlockTunes) {
this.activateBlockSettings();
}
}
/**
* Add drop target styles
*
@ -213,6 +185,62 @@ export default class BlockEvents extends Module {
});
}
/**
* Tab pressed inside a Block.
*
* @param {KeyboardEvent} event - keydown
*/
private tabPressed(event: KeyboardEvent): void {
const { InlineToolbar, ConversionToolbar, Caret } = this.Editor;
const isFlipperActivated = ConversionToolbar.opened || InlineToolbar.opened;
if (isFlipperActivated) {
return;
}
const isNavigated = event.shiftKey ? Caret.navigatePrevious(true) : Caret.navigateNext(true);
/**
* If we have next Block/input to focus, then focus it. Otherwise, leave native Tab behaviour
*/
if (isNavigated) {
event.preventDefault();
}
}
/**
* '/' + 'command' keydown inside a Block
*/
private commandSlashPressed(): void {
if (this.Editor.BlockSelection.selectedBlocks.length > 1) {
return;
}
this.activateBlockSettings();
}
/**
* '/' keydown inside a Block
*/
private slashPressed(): void {
const currentBlock = this.Editor.BlockManager.currentBlock;
const canOpenToolbox = currentBlock.isEmpty;
/**
* @todo Handle case when slash pressed when several blocks are selected
*/
/**
* Toolbox will be opened only if Block is empty
*/
if (!canOpenToolbox) {
return;
}
this.activateToolbox();
}
/**
* ENTER pressed on block
*
@ -481,9 +509,8 @@ export default class BlockEvents extends Module {
}
/**
* Close Toolbar and highlighting when user moves cursor
* Close Toolbar when user moves cursor
*/
this.Editor.BlockManager.clearFocused();
this.Editor.Toolbar.close();
const shouldEnableCBS = this.Editor.Caret.isAtEnd || this.Editor.BlockSelection.anyBlockSelected;
@ -502,19 +529,21 @@ export default class BlockEvents extends Module {
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
} else {
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user moves selection out of Editor */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
return;
}
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user moves selection out of Editor */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
/**
* Clear blocks selection by arrows
*/
@ -540,9 +569,8 @@ export default class BlockEvents extends Module {
}
/**
* Close Toolbar and highlighting when user moves cursor
* Close Toolbar when user moves cursor
*/
this.Editor.BlockManager.clearFocused();
this.Editor.Toolbar.close();
const shouldEnableCBS = this.Editor.Caret.isAtStart || this.Editor.BlockSelection.anyBlockSelected;
@ -561,19 +589,21 @@ export default class BlockEvents extends Module {
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
} else {
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user ends selection out of Editor and then press arrow-key */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
return;
}
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user ends selection out of Editor and then press arrow-key */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
/**
* Clear blocks selection by arrows
*/
@ -623,7 +653,6 @@ export default class BlockEvents extends Module {
*/
private activateBlockSettings(): void {
if (!this.Editor.Toolbar.opened) {
this.Editor.BlockManager.currentBlock.focused = true;
this.Editor.Toolbar.moveAndOpen();
}

View file

@ -663,32 +663,6 @@ export default class BlockManager extends Module {
}
}
/**
* Remove selection from all Blocks then highlight only Current Block
*/
public highlightCurrentNode(): void {
/**
* Remove previous selected Block's state
*/
this.clearFocused();
/**
* Mark current Block as selected
*
* @type {boolean}
*/
this.currentBlock.focused = true;
}
/**
* Remove selection from all Blocks
*/
public clearFocused(): void {
this.blocks.forEach((block) => {
block.focused = false;
});
}
/**
* 1) Find first-level Block from passed child Node
* 2) Mark it as current
@ -873,7 +847,6 @@ export default class BlockManager extends Module {
*/
public dropPointer(): void {
this.currentBlockIndex = -1;
this.clearFocused();
}
/**

View file

@ -321,26 +321,28 @@ export default class BlockSelection extends Module {
}
/**
* select Block
* Select Block by its index
*
* @param {number?} index - Block index according to the BlockManager's indexes
*/
public selectBlockByIndex(index?): void {
public selectBlockByIndex(index: number): void {
const { BlockManager } = this.Editor;
/**
* Remove previous focused Block's state
*/
BlockManager.clearFocused();
const block = BlockManager.getBlockByIndex(index);
let block;
if (isNaN(index)) {
block = BlockManager.currentBlock;
} else {
block = BlockManager.getBlockByIndex(index);
if (block === undefined) {
return;
}
this.selectBlock(block);
}
/**
* Select passed Block
*
* @param {Block} block - Block to select
*/
public selectBlock(block: Block): void {
/** Save selection */
this.selection.save();
SelectionUtils.get()
@ -354,6 +356,17 @@ export default class BlockSelection extends Module {
this.Editor.InlineToolbar.close();
}
/**
* Remove selection from passed Block
*
* @param {Block} block - Block to unselect
*/
public unselectBlock(block: Block): void {
block.selected = false;
this.clearCache();
}
/**
* Clear anyBlockSelected cache
*/
@ -432,7 +445,7 @@ export default class BlockSelection extends Module {
/**
* select working Block
*/
this.selectBlockByIndex();
this.selectBlock(workingBlock);
/**
* Enable all Blocks selection if current Block is selected

View file

@ -46,8 +46,17 @@ export default class Caret extends Module {
* @returns {boolean}
*/
public get isAtStart(): boolean {
const { currentBlock } = this.Editor.BlockManager;
/**
* If Block does not contain inputs, treat caret as "at start"
*/
if (!currentBlock.focusable) {
return true;
}
const selection = Selection.get();
const firstNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput);
const firstNode = $.getDeepestNode(currentBlock.currentInput);
let focusNode = selection.focusNode;
/** In case lastNode is native input */
@ -138,10 +147,19 @@ export default class Caret extends Module {
* @returns {boolean}
*/
public get isAtEnd(): boolean {
const { currentBlock } = this.Editor.BlockManager;
/**
* If Block does not contain inputs, treat caret as "at end"
*/
if (!currentBlock.focusable) {
return true;
}
const selection = Selection.get();
let focusNode = selection.focusNode;
const lastNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput, true);
const lastNode = $.getDeepestNode(currentBlock.currentInput, true);
/** In case lastNode is native input */
if ($.isNativeInput(lastNode)) {
@ -224,7 +242,31 @@ export default class Caret extends Module {
* @param {number} offset - caret offset regarding to the text node
*/
public setToBlock(block: Block, position: string = this.positions.DEFAULT, offset = 0): void {
const { BlockManager } = this.Editor;
const { BlockManager, BlockSelection } = this.Editor;
/**
* Clear previous selection since we possible will select the new Block
*/
BlockSelection.clearSelection();
/**
* If Block is not focusable, just select (highlight) it
*/
if (!block.focusable) {
/**
* Hide current cursor
*/
window.getSelection()?.removeAllRanges();
/**
* Highlight Block
*/
BlockSelection.selectBlock(block);
BlockManager.currentBlock = block;
return;
}
let element;
switch (position) {
@ -388,17 +430,25 @@ export default class Caret extends Module {
* Before moving caret, we should check if caret position is at the end of Plugins node
* Using {@link Dom#getDeepestNode} to get a last node and match with current selection
*
* @returns {boolean}
* @param {boolean} force - pass true to skip check for caret position
*/
public navigateNext(): boolean {
public navigateNext(force = false): boolean {
const { BlockManager } = this.Editor;
const { currentBlock, nextContentfulBlock } = BlockManager;
const { currentBlock, nextBlock } = BlockManager;
const { nextInput } = currentBlock;
const isAtEnd = this.isAtEnd;
let blockToNavigate = nextBlock;
let nextBlock = nextContentfulBlock;
const navigationAllowed = force || isAtEnd;
if (!nextBlock && !nextInput) {
/** If next Tool`s input exists, focus on it. Otherwise set caret to the next Block */
if (nextInput && navigationAllowed) {
this.setToInput(nextInput, this.positions.START);
return true;
}
if (blockToNavigate === null) {
/**
* This code allows to exit from the last non-initial tool:
* https://github.com/codex-team/editor.js/issues/1103
@ -409,7 +459,7 @@ export default class Caret extends Module {
* 2. If there is a last block and it is non-default --> and caret not at the end <--, do nothing
* (https://github.com/codex-team/editor.js/issues/1414)
*/
if (currentBlock.tool.isDefault || !isAtEnd) {
if (currentBlock.tool.isDefault || !navigationAllowed) {
return false;
}
@ -417,16 +467,11 @@ export default class Caret extends Module {
* If there is no nextBlock, but currentBlock is not default,
* insert new default block at the end and navigate to it
*/
nextBlock = BlockManager.insertAtEnd();
blockToNavigate = BlockManager.insertAtEnd() as Block;
}
if (isAtEnd) {
/** If next Tool`s input exists, focus on it. Otherwise set caret to the next Block */
if (!nextInput) {
this.setToBlock(nextBlock, this.positions.START);
} else {
this.setToInput(nextInput, this.positions.START);
}
if (navigationAllowed) {
this.setToBlock(blockToNavigate, this.positions.START);
return true;
}
@ -439,28 +484,27 @@ export default class Caret extends Module {
* Before moving caret, we should check if caret position is start of the Plugins node
* Using {@link Dom#getDeepestNode} to get a last node and match with current selection
*
* @returns {boolean}
* @param {boolean} force - pass true to skip check for caret position
*/
public navigatePrevious(): boolean {
const { currentBlock, previousContentfulBlock } = this.Editor.BlockManager;
public navigatePrevious(force = false): boolean {
const { currentBlock, previousBlock } = this.Editor.BlockManager;
if (!currentBlock) {
return false;
}
const { previousInput } = currentBlock;
const navigationAllowed = force || this.isAtStart;
if (!previousContentfulBlock && !previousInput) {
return false;
/** If previous Tool`s input exists, focus on it. Otherwise set caret to the previous Block */
if (previousInput && navigationAllowed) {
this.setToInput(previousInput, this.positions.END);
return true;
}
if (this.isAtStart) {
/** If previous Tool`s input exists, focus on it. Otherwise set caret to the previous Block */
if (!previousInput) {
this.setToBlock(previousContentfulBlock, this.positions.END);
} else {
this.setToInput(previousInput, this.positions.END);
}
if (previousBlock !== null && navigationAllowed) {
this.setToBlock(previousBlock as Block, this.positions.END);
return true;
}

View file

@ -130,11 +130,6 @@ export default class CrossBlockSelection extends Module {
default:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
} else {
/**
* By default set caret at the end of the last selected block
*/
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
}

View file

@ -501,7 +501,6 @@ export default class Paste extends Module {
event.preventDefault();
this.processDataTransfer(event.clipboardData);
BlockManager.clearFocused();
Toolbar.close();
};

View file

@ -78,6 +78,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
public make(): void {
this.nodes.wrapper = $.make('div', [ this.CSS.settings ]);
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'block-tunes');
}
}
/**
@ -104,7 +108,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Highlight content of a Block we are working with
*/
targetBlock.selected = true;
this.Editor.BlockSelection.selectBlock(targetBlock);
this.Editor.BlockSelection.clearCache();
/**
@ -144,6 +148,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* Close Block Settings pane
*/
public close(): void {
if (!this.opened) {
return;
}
this.opened = false;
/**
@ -163,7 +171,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* Remove highlighted content of a Block we are working with
*/
if (!this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted && this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.selected = false;
this.Editor.BlockSelection.unselectBlock(this.Editor.BlockManager.currentBlock);
}
/** Tell to subscribers that block settings is closed */

View file

@ -9,6 +9,7 @@ import Block from '../../block';
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
import { IconMenu, IconPlus } from '@codexteam/icons';
import { BlockHovered } from '../../events/BlockHovered';
import { beautifyShortcut } from '../../utils';
/**
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
@ -392,7 +393,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
tooltipContent.appendChild(document.createTextNode(I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Add')));
tooltipContent.appendChild($.make('div', this.CSS.plusButtonShortcut, {
textContent: '⇥ Tab',
textContent: '/',
}));
tooltip.onHover(this.nodes.plusButton, tooltipContent, {
@ -411,13 +412,17 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.actions, this.nodes.settingsToggler);
tooltip.onHover(
this.nodes.settingsToggler,
I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'),
{
hidingDelay: 400,
}
);
const blockTunesTooltip = $.make('div');
const blockTunesTooltipEl = $.text(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'));
blockTunesTooltip.appendChild(blockTunesTooltipEl);
blockTunesTooltip.appendChild($.make('div', this.CSS.plusButtonShortcut, {
textContent: beautifyShortcut('CMD + /'),
}));
tooltip.onHover(this.nodes.settingsToggler, blockTunesTooltip, {
hidingDelay: 400,
});
/**
* Appending Toolbar components to itself

View file

@ -537,7 +537,7 @@ export default class UI extends Module<UINodes> {
if (this.Editor.Toolbar.toolbox.opened) {
this.Editor.Toolbar.toolbox.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock, this.Editor.Caret.positions.END);
} else if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else if (this.Editor.ConversionToolbar.opened) {
@ -593,11 +593,6 @@ export default class UI extends Module<UINodes> {
this.Editor.Caret.setToBlock(newBlock);
/**
* And highlight
*/
this.Editor.BlockManager.highlightCurrentNode();
/**
* Move toolbar and show plus button because new Block is empty
*/
@ -691,11 +686,6 @@ export default class UI extends Module<UINodes> {
*/
try {
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
/**
* Highlight Current Node
*/
this.Editor.BlockManager.highlightCurrentNode();
} catch (e) {
/**
* If clicked outside first-level Blocks and it is not RectSelection, set Caret to the last empty Block

View file

@ -154,6 +154,10 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
this.nodes.toolbox = this.popover.getElement();
this.nodes.toolbox.classList.add(Toolbox.CSS.toolbox);
if (import.meta.env.MODE === 'test') {
this.nodes.toolbox.setAttribute('data-cy', 'toolbox');
}
return this.nodes.toolbox;
}

View file

@ -56,6 +56,7 @@ export const keyCodes = {
RIGHT: 39,
DELETE: 46,
META: 91,
SLASH: 191,
};
/**

View file

@ -212,8 +212,8 @@ export default class Popover extends EventsDispatcher<PopoverEventMap> {
/**
* Returns HTML element corresponding to the popover
*/
public getElement(): HTMLElement | null {
return this.nodes.wrapper;
public getElement(): HTMLElement {
return this.nodes.wrapper as HTMLElement;
}
/**
@ -237,10 +237,9 @@ export default class Popover extends EventsDispatcher<PopoverEventMap> {
this.flipper.activate(this.flippableElements);
if (this.search !== undefined) {
setTimeout(() => {
this.search.focus();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 100);
requestAnimationFrame(() => {
this.search?.focus();
});
}
if (isMobileScreen()) {

View file

@ -120,6 +120,12 @@ export default class SearchInput {
this.input = Dom.make('input', SearchInput.CSS.input, {
placeholder,
/**
* Used to prevent focusing on the input by Tab key
* (Popover in the Toolbar lays below the blocks,
* so Tab in the last block will focus this hidden input if this property is not set)
*/
tabIndex: -1,
}) as HTMLInputElement;
this.wrapper.appendChild(iconWrapper);

View file

@ -89,10 +89,3 @@
font-style: italic;
}
}
.codex-editor--narrow .ce-block--focused {
@media (--not-mobile) {
margin-right: calc(var(--narrow-mode-right-padding) * -1);
padding-right: var(--narrow-mode-right-padding);
}
}

View file

@ -8,6 +8,7 @@
import '@cypress/code-coverage/support';
import installLogsCollector from 'cypress-terminal-report/src/installLogsCollector';
import 'cypress-plugin-tab';
installLogsCollector();

View file

@ -0,0 +1,87 @@
describe('Slash keydown', function () {
describe('pressed in empty block', function () {
it('should open Toolbox', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('/');
cy.get('[data-cy="toolbox"]')
.get('.ce-popover')
.should('be.visible');
});
});
describe('pressed in non-empty block', function () {
it('should not open Toolbox and just add the / char', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('/');
cy.get('[data-cy="toolbox"]')
.get('.ce-popover')
.should('not.be.visible');
/**
* Block content should contain slash
*/
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.invoke('text')
.should('eq', 'Hello/');
});
});
});
describe('CMD+Slash keydown', function () {
it('should open Block Tunes', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{cmd}/');
cy.get('[data-cy="block-tunes"]')
.get('.ce-popover')
.should('be.visible');
});
});

View file

@ -0,0 +1,370 @@
import ToolMock from '../../../fixtures/tools/ToolMock';
/**
* Mock of tool that contains two inputs
*/
class ToolWithTwoInputs extends ToolMock {
/**
* Create element with two inputs
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
const input1 = document.createElement('div');
const input2 = document.createElement('div');
input1.contentEditable = 'true';
input2.contentEditable = 'true';
wrapper.setAttribute('data-cy', 'tool-with-two-inputs');
wrapper.appendChild(input1);
wrapper.appendChild(input2);
return wrapper;
}
}
/**
* Mock of tool without inputs
*/
class ContentlessTool extends ToolMock {
public static contentless = true;
/**
* Create element without inputs
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-cy', 'contentless-tool');
wrapper.textContent = '***';
return wrapper;
}
}
/**
* Time to wait for caret to finish moving
*/
const CARET_MOVE_TIME = 100;
describe('Tab keydown', function () {
it('should focus next Block if Block contains only one input', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'first paragraph',
},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.trigger('keydown', { keyCode: 9 })
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.then(($secondBlock) => {
const editorWindow = $secondBlock.get(0).ownerDocument.defaultView;
const selection = editorWindow.getSelection();
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer.parentElement).to.equal($secondBlock.get(0));
});
});
it('should focus next input if Block contains several inputs', () => {
cy.createEditor({
tools: {
toolWithTwoInputs: {
class: ToolWithTwoInputs,
},
},
data: {
blocks: [
{
type: 'toolWithTwoInputs',
data: {},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.first()
.click()
.trigger('keydown', { keyCode: 9 })
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.last()
.then(($secondInput) => {
const editorWindow = $secondInput.get(0).ownerDocument.defaultView;
const selection = editorWindow.getSelection();
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer).to.equal($secondInput.get(0));
});
});
it('should highlight next Block if it does not contain any inputs (contentless Block)', () => {
cy.createEditor({
tools: {
contentlessTool: {
class: ContentlessTool,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'contentlessTool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'third paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.trigger('keydown', { keyCode: 9 })
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=contentless-tool]')
.parents('.ce-block')
.should('have.class', 'ce-block--selected');
});
it('should focus next input after Editor when pressed in last Block', () => {
cy.createEditor({});
/**
* Add regular input after Editor
*/
cy.window()
.then((window) => {
const input = window.document.createElement('input');
input.setAttribute('data-cy', 'regular-input');
window.document.body.appendChild(input);
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.tab();
cy.get('[data-cy=regular-input]')
.should('have.focus');
});
});
describe('Shift+Tab keydown', function () {
it('should focus previous Block if Block contains only one input', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'first paragraph',
},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.trigger('keydown', {
keyCode: 9,
shiftKey: true,
})
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.then(($firstBlock) => {
const editorWindow = $firstBlock.get(0).ownerDocument.defaultView;
const selection = editorWindow.getSelection();
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer.parentElement).to.equal($firstBlock.get(0));
});
});
it('should focus previous input if Block contains several inputs', () => {
cy.createEditor({
tools: {
toolWithTwoInputs: {
class: ToolWithTwoInputs,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'toolWithTwoInputs',
data: {},
},
],
},
});
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.last()
.click()
.trigger('keydown', {
keyCode: 9,
shiftKey: true,
})
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.first()
.then(($firstInput) => {
const editorWindow = $firstInput.get(0).ownerDocument.defaultView;
const selection = editorWindow.getSelection();
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer).to.equal($firstInput.get(0));
});
});
it('should highlight previous Block if it does not contain any inputs (contentless Block)', () => {
cy.createEditor({
tools: {
contentlessTool: {
class: ContentlessTool,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'contentlessTool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'third paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.trigger('keydown', {
keyCode: 9,
shiftKey: true,
})
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=contentless-tool]')
.parents('.ce-block')
.should('have.class', 'ce-block--selected');
});
it('should focus previous input before Editor when pressed in first Block', () => {
cy.createEditor({});
/**
* Add regular input before Editor
*/
cy.window()
.then((window) => {
const input = window.document.createElement('input');
input.setAttribute('data-cy', 'regular-input');
window.document.body.insertBefore(input, window.document.body.firstChild);
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.tab({ shift: true });
cy.get('[data-cy=regular-input]')
.should('have.focus');
});
});

View file

@ -38,7 +38,7 @@ class SomePlugin {
describe('Flipper', () => {
it('should prevent plugins event handlers from being called while keyboard navigation', () => {
const TAB_KEY_CODE = 9;
const SLASH_KEY_CODE = 191;
const ARROW_DOWN_KEY_CODE = 40;
const ENTER_KEY_CODE = 13;
@ -63,6 +63,7 @@ describe('Flipper', () => {
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
.as('pluginInput')
.focus()
.type(sampleText)
.wait(100);
@ -71,7 +72,7 @@ describe('Flipper', () => {
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
// Open tunes menu
.trigger('keydown', { keyCode: TAB_KEY_CODE })
.trigger('keydown', { keyCode: SLASH_KEY_CODE, ctrlKey: true })
// Navigate to delete button (the second button)
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE })
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE });

View file

@ -35,6 +35,11 @@ export interface BlockAPI {
*/
readonly selected: boolean;
/**
* True if Block has inputs to be focused
*/
readonly focusable: boolean;
/**
* Setter sets Block's stretch state
*

View file

@ -1011,6 +1011,14 @@ ajv@^8.0.1:
require-from-string "^2.0.2"
uri-js "^4.2.2"
ally.js@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/ally.js/-/ally.js-1.4.1.tgz#9fb7e6ba58efac4ee9131cb29aa9ee3b540bcf1e"
integrity sha512-ZewdfuwP6VewtMN36QY0gmiyvBfMnmEaNwbVu2nTS6zRt069viTgkYgaDiqu6vRJ1VJCriNqV0jGMu44R8zNbA==
dependencies:
css.escape "^1.5.0"
platform "1.3.3"
ansi-colors@4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
@ -1627,6 +1635,11 @@ css-tree@^2.3.1:
mdn-data "2.0.30"
source-map-js "^1.0.1"
css.escape@^1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==
cssdb@^7.5.3:
version "7.5.3"
resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-7.5.3.tgz#6bbd0c6a935919d7f78b8a3ce098faacda01ae8a"
@ -1649,6 +1662,13 @@ cypress-intellij-reporter@^0.0.7:
dependencies:
mocha latest
cypress-plugin-tab@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/cypress-plugin-tab/-/cypress-plugin-tab-1.0.5.tgz#a40714148104004bb05ed62b1bf46bb544f8eb4a"
integrity sha512-QtTJcifOVwwbeMP3hsOzQOKf3EqKsLyjtg9ZAGlYDntrCRXrsQhe4ZQGIthRMRLKpnP6/tTk6G0gJ2sZUfRliQ==
dependencies:
ally.js "^1.4.1"
cypress-terminal-report@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/cypress-terminal-report/-/cypress-terminal-report-5.3.2.tgz#3a6b1cbda6101498243d17c5a2a646cb69af0336"
@ -3905,6 +3925,11 @@ pkg-dir@^4.1.0:
dependencies:
find-up "^4.0.0"
platform@1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.3.tgz#646c77011899870b6a0903e75e997e8e51da7461"
integrity sha512-VJK1SRmXBpjwsB4YOHYSturx48rLKMzHgCqDH2ZDa6ZbMS/N5huoNqyQdK5Fj/xayu3fqbXckn5SeCS1EbMDZg==
postcss-apply@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/postcss-apply/-/postcss-apply-0.12.0.tgz#11a47b271b14d81db97ed7f51a6c409d025a9c34"