slash to open toolbox, tab for navigation

This commit is contained in:
Peter Savchenko 2023-12-14 20:39:07 +03:00
parent ea2be754e7
commit 0004b41f8f
No known key found for this signature in database
GPG key ID: E68306B1AB0F727C
12 changed files with 144 additions and 52 deletions

View file

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

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

@ -392,13 +392,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;

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;
}
}
@ -113,40 +120,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 +186,87 @@ export default class BlockEvents extends Module {
});
}
/**
* Tab pressed inside a Block.
*
* @param {KeyboardEvent} event - keydown
*/
private tabPressed(event: KeyboardEvent): void {
/**
* Clear blocks selection by tab
*/
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockManager.clearFocused();
const { BlockManager, InlineToolbar, ConversionToolbar, Caret } = this.Editor;
const isFlipperActivated = ConversionToolbar.opened || InlineToolbar.opened;
if (isFlipperActivated) {
return;
}
/**
* Block to be focused by tab
*/
const nextBlock: Block | null | undefined = BlockManager.nextBlock;
/**
* If we have next Block to focus, then focus it. Otherwise, leave native Tab behaviour
*/
if (nextBlock !== null) {
event.preventDefault();
/**
* If next Block is not focusable, just select (highlight) it
*/
if (!nextBlock.focusable) {
/**
* Hide current cursor
*/
window.getSelection()?.removeAllRanges();
/**
* Highlight Block
*/
nextBlock.selected = true;
BlockManager.currentBlock = nextBlock;
return;
} else {
Caret.setToBlock(nextBlock, Caret.positions.START);
}
}
}
/**
* '/' + 'command' keydown inside a Block
*/
private commandSlashPressed(): void {
if (this.Editor.BlockSelection.selectedBlocks.length > 1) {
return;
}
this.Editor.BlockSelection.clearSelection();
this.activateBlockSettings();
}
/**
* '/' keydown inside a Block
*/
private slashPressed(): void {
const currentBlock = this.Editor.BlockManager.currentBlock;
const canOpenToolbox = currentBlock.isEmpty;
/**
* Toolbox will be opened only if Block is empty
*/
if (!canOpenToolbox) {
return;
}
this.activateToolbox();
}
/**
* ENTER pressed on block
*

View file

@ -686,6 +686,7 @@ export default class BlockManager extends Module {
public clearFocused(): void {
this.blocks.forEach((block) => {
block.focused = false;
block.selected = false;
});
}

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,16 @@ 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');
blockTunesTooltip.appendChild(document.createTextNode(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune')));
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

@ -249,6 +249,7 @@ export default class UI extends Module<UINodes> {
* @type {Element}
*/
this.nodes.holder = $.getHolder(this.config.holder);
this.nodes.holder.tabIndex = -1;
/**
* Create and save main UI elements
@ -537,7 +538,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) {

View file

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

View file

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

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