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