editor.js/src/components/modules/blockEvents.ts
Peter Savchenko cd29c52e51
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
2023-12-22 23:15:35 +03:00

673 lines
18 KiB
TypeScript

/**
* Contains keyboard and mouse events bound on each Block by Block Manager
*/
import Module from '../__module';
import * as _ from '../utils';
import SelectionUtils from '../selection';
import Flipper from '../flipper';
import type Block from '../block';
import { areBlocksMergeable } from '../utils/blocks';
/**
*
*/
export default class BlockEvents extends Module {
/**
* All keydowns on Block
*
* @param {KeyboardEvent} event - keydown
*/
public keydown(event: KeyboardEvent): void {
/**
* Run common method for all keydown events
*/
this.beforeKeydownProcessing(event);
/**
* Fire keydown processor by event.keyCode
*/
switch (event.keyCode) {
case _.keyCodes.BACKSPACE:
this.backspace(event);
break;
case _.keyCodes.DELETE:
this.delete(event);
break;
case _.keyCodes.ENTER:
this.enter(event);
break;
case _.keyCodes.DOWN:
case _.keyCodes.RIGHT:
this.arrowRightAndDown(event);
break;
case _.keyCodes.UP:
case _.keyCodes.LEFT:
this.arrowLeftAndUp(event);
break;
case _.keyCodes.TAB:
this.tabPressed(event);
break;
case _.keyCodes.SLASH:
if (event.ctrlKey || event.metaKey) {
this.commandSlashPressed();
} else {
this.slashPressed();
}
break;
}
}
/**
* Fires on keydown before event processing
*
* @param {KeyboardEvent} event - keydown
*/
public beforeKeydownProcessing(event: KeyboardEvent): void {
/**
* Do not close Toolbox on Tabs or on Enter with opened Toolbox
*/
if (!this.needToolbarClosing(event)) {
return;
}
/**
* When user type something:
* - close Toolbar
* - close Conversion Toolbar
* - clear block highlighting
*/
if (_.isPrintableKey(event.keyCode)) {
this.Editor.Toolbar.close();
this.Editor.ConversionToolbar.close();
/**
* Allow to use shortcuts with selected blocks
*
* @type {boolean}
*/
const isShortcut = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
if (!isShortcut) {
this.Editor.BlockSelection.clearSelection(event);
}
}
}
/**
* Key up on Block:
* - shows Inline Toolbar if something selected
* - shows conversion toolbar with 85% of block selection
*
* @param {KeyboardEvent} event - keyup event
*/
public keyup(event: KeyboardEvent): void {
/**
* If shift key was pressed some special shortcut is used (eg. cross block selection via shift + arrows)
*/
if (event.shiftKey) {
return;
}
/**
* Check if editor is empty on each keyup and add special css class to wrapper
*/
this.Editor.UI.checkEmptiness();
}
/**
* Add drop target styles
*
* @param {DragEvent} event - drag over event
*/
public dragOver(event: DragEvent): void {
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
block.dropTarget = true;
}
/**
* Remove drop target style
*
* @param {DragEvent} event - drag leave event
*/
public dragLeave(event: DragEvent): void {
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
block.dropTarget = false;
}
/**
* Copying selected blocks
* Before putting to the clipboard we sanitize all blocks and then copy to the clipboard
*
* @param {ClipboardEvent} event - clipboard event
*/
public handleCommandC(event: ClipboardEvent): void {
const { BlockSelection } = this.Editor;
if (!BlockSelection.anyBlockSelected) {
return;
}
// Copy Selected Blocks
BlockSelection.copySelectedBlocks(event);
}
/**
* Copy and Delete selected Blocks
*
* @param {ClipboardEvent} event - clipboard event
*/
public handleCommandX(event: ClipboardEvent): void {
const { BlockSelection, BlockManager, Caret } = this.Editor;
if (!BlockSelection.anyBlockSelected) {
return;
}
BlockSelection.copySelectedBlocks(event).then(() => {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
/**
* Insert default block in place of removed ones
*/
const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(insertedBlock, Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection(event);
});
}
/**
* 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
*
* @param {KeyboardEvent} event - keydown
*/
private enter(event: KeyboardEvent): void {
const { BlockManager, UI } = this.Editor;
const currentBlock = BlockManager.currentBlock;
/**
* Don't handle Enter keydowns when Tool sets enableLineBreaks to true.
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
*/
if (currentBlock.tool.isLineBreaksEnabled) {
return;
}
/**
* Opened Toolbars uses Flipper with own Enter handling
* Allow split block when no one button in Flipper is focused
*/
if (UI.someToolbarOpened && UI.someFlipperButtonFocused) {
return;
}
/**
* Allow to create line breaks by Shift+Enter
*/
if (event.shiftKey) {
return;
}
let newCurrent = this.Editor.BlockManager.currentBlock;
/**
* If enter has been pressed at the start of the text, just insert paragraph Block above
*/
if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) {
this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex);
/**
* If caret is at very end of the block, just append the new block without splitting
* to prevent unnecessary dom mutation observing
*/
} else if (this.Editor.Caret.isAtEnd) {
newCurrent = this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1);
} else {
/**
* Split the Current Block into two blocks
* Renew local current node after split
*/
newCurrent = this.Editor.BlockManager.split();
}
this.Editor.Caret.setToBlock(newCurrent);
/**
* Show Toolbar
*/
this.Editor.Toolbar.moveAndOpen(newCurrent);
event.preventDefault();
}
/**
* Handle backspace keydown on Block
*
* @param {KeyboardEvent} event - keydown
*/
private backspace(event: KeyboardEvent): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock, previousBlock } = BlockManager;
/**
* If some fragment is selected, leave native behaviour
*/
if (!SelectionUtils.isCollapsed) {
return;
}
/**
* If caret is not at the start, leave native behaviour
*/
if (!Caret.isAtStart) {
return;
}
/**
* All the cases below have custom behaviour, so we don't need a native one
*/
event.preventDefault();
this.Editor.Toolbar.close();
const isFirstInputFocused = currentBlock.currentInput === currentBlock.firstInput;
/**
* For example, caret at the start of the Quote second input (caption) — just navigate previous input
*/
if (!isFirstInputFocused) {
Caret.navigatePrevious();
return;
}
/**
* Backspace at the start of the first Block should do nothing
*/
if (previousBlock === null) {
return;
}
/**
* If prev Block is empty, it should be removed just like a character
*/
if (previousBlock.isEmpty) {
BlockManager.removeBlock(previousBlock);
return;
}
/**
* If current Block is empty, just remove it and set cursor to the previous Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
const newCurrentBlock = BlockManager.currentBlock;
Caret.setToBlock(newCurrentBlock, Caret.positions.END);
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, previousBlock);
/**
* If Blocks could be merged, do it
* Otherwise, just navigate previous block
*/
if (bothBlocksMergeable) {
this.mergeBlocks(previousBlock, currentBlock);
} else {
Caret.setToBlock(previousBlock, Caret.positions.END);
}
}
/**
* Handles delete keydown on Block
* Removes char after the caret.
* If caret is at the end of the block, merge next block with current
*
* @param {KeyboardEvent} event - keydown
*/
private delete(event: KeyboardEvent): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock, nextBlock } = BlockManager;
/**
* If some fragment is selected, leave native behaviour
*/
if (!SelectionUtils.isCollapsed) {
return;
}
/**
* If caret is not at the end, leave native behaviour
*/
if (!Caret.isAtEnd) {
return;
}
/**
* All the cases below have custom behaviour, so we don't need a native one
*/
event.preventDefault();
this.Editor.Toolbar.close();
const isLastInputFocused = currentBlock.currentInput === currentBlock.lastInput;
/**
* For example, caret at the end of the Quote first input (quote text) — just navigate next input (caption)
*/
if (!isLastInputFocused) {
Caret.navigateNext();
return;
}
/**
* Delete at the end of the last Block should do nothing
*/
if (nextBlock === null) {
return;
}
/**
* If next Block is empty, it should be removed just like a character
*/
if (nextBlock.isEmpty) {
BlockManager.removeBlock(nextBlock);
return;
}
/**
* If current Block is empty, just remove it and set cursor to the next Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
Caret.setToBlock(nextBlock, Caret.positions.START);
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, nextBlock);
/**
* If Blocks could be merged, do it
* Otherwise, just navigate to the next block
*/
if (bothBlocksMergeable) {
this.mergeBlocks(currentBlock, nextBlock);
} else {
Caret.setToBlock(nextBlock, Caret.positions.START);
}
}
/**
* Merge passed Blocks
*
* @param targetBlock - to which Block we want to merge
* @param blockToMerge - what Block we want to merge
*/
private mergeBlocks(targetBlock: Block, blockToMerge: Block): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
Caret.createShadow(targetBlock.pluginsContent);
BlockManager
.mergeBlocks(targetBlock, blockToMerge)
.then(() => {
window.requestAnimationFrame(() => {
/** Restore caret position after merge */
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
targetBlock.pluginsContent.normalize();
Toolbar.close();
});
});
}
/**
* Handle right and down keyboard keys
*
* @param {KeyboardEvent} event - keyboard event
*/
private arrowRightAndDown(event: KeyboardEvent): void {
const isFlipperCombination = Flipper.usedKeys.includes(event.keyCode) &&
(!event.shiftKey || event.keyCode === _.keyCodes.TAB);
/**
* Arrows might be handled on toolbars by flipper
* Check for Flipper.usedKeys to allow navigate by DOWN and disallow by RIGHT
*/
if (this.Editor.UI.someToolbarOpened && isFlipperCombination) {
return;
}
/**
* Close Toolbar when user moves cursor
*/
this.Editor.Toolbar.close();
const shouldEnableCBS = this.Editor.Caret.isAtEnd || this.Editor.BlockSelection.anyBlockSelected;
if (event.shiftKey && event.keyCode === _.keyCodes.DOWN && shouldEnableCBS) {
this.Editor.CrossBlockSelection.toggleBlockSelectedState();
return;
}
const navigateNext = event.keyCode === _.keyCodes.DOWN || (event.keyCode === _.keyCodes.RIGHT && !this.isRtl);
const isNavigated = navigateNext ? this.Editor.Caret.navigateNext() : this.Editor.Caret.navigatePrevious();
if (isNavigated) {
/**
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
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
*/
this.Editor.BlockSelection.clearSelection(event);
}
/**
* Handle left and up keyboard keys
*
* @param {KeyboardEvent} event - keyboard event
*/
private arrowLeftAndUp(event: KeyboardEvent): void {
/**
* Arrows might be handled on toolbars by flipper
* Check for Flipper.usedKeys to allow navigate by UP and disallow by LEFT
*/
if (this.Editor.UI.someToolbarOpened) {
if (Flipper.usedKeys.includes(event.keyCode) && (!event.shiftKey || event.keyCode === _.keyCodes.TAB)) {
return;
}
this.Editor.UI.closeAllToolbars();
}
/**
* Close Toolbar when user moves cursor
*/
this.Editor.Toolbar.close();
const shouldEnableCBS = this.Editor.Caret.isAtStart || this.Editor.BlockSelection.anyBlockSelected;
if (event.shiftKey && event.keyCode === _.keyCodes.UP && shouldEnableCBS) {
this.Editor.CrossBlockSelection.toggleBlockSelectedState(false);
return;
}
const navigatePrevious = event.keyCode === _.keyCodes.UP || (event.keyCode === _.keyCodes.LEFT && !this.isRtl);
const isNavigated = navigatePrevious ? this.Editor.Caret.navigatePrevious() : this.Editor.Caret.navigateNext();
if (isNavigated) {
/**
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
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
*/
this.Editor.BlockSelection.clearSelection(event);
}
/**
* Cases when we need to close Toolbar
*
* @param {KeyboardEvent} event - keyboard event
*/
private needToolbarClosing(event: KeyboardEvent): boolean {
const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened),
blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened),
inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened),
conversionToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.ConversionToolbar.opened),
flippingToolbarItems = event.keyCode === _.keyCodes.TAB;
/**
* Do not close Toolbar in cases:
* 1. ShiftKey pressed (or combination with shiftKey)
* 2. When Toolbar is opened and Tab leafs its Tools
* 3. When Toolbar's component is opened and some its item selected
*/
return !(event.shiftKey ||
flippingToolbarItems ||
toolboxItemSelected ||
blockSettingsItemSelected ||
inlineToolbarItemSelected ||
conversionToolbarItemSelected
);
}
/**
* If Toolbox is not open, then just open it and show plus button
*/
private activateToolbox(): void {
if (!this.Editor.Toolbar.opened) {
this.Editor.Toolbar.moveAndOpen();
} // else Flipper will leaf through it
this.Editor.Toolbar.toolbox.open();
}
/**
* Open Toolbar and show BlockSettings before flipping Tools
*/
private activateBlockSettings(): void {
if (!this.Editor.Toolbar.opened) {
this.Editor.Toolbar.moveAndOpen();
}
/**
* If BlockSettings is not open, then open BlockSettings
* Next Tab press will leaf Settings Buttons
*/
if (!this.Editor.BlockSettings.opened) {
/**
* @todo Debug the case when we set caret to some block, hovering another block
* — wrong settings will be opened.
* To fix it, we should refactor the Block Settings module — make it a standalone class, like the Toolbox
*/
this.Editor.BlockSettings.open();
}
}
}