diff --git a/.eslintrc b/.eslintrc index ef566548..3ee5e6f0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -31,6 +31,7 @@ "ClientRect": true, "ArrayLike": true, "InputEvent": true, - "unknown": true + "unknown": true, + "requestAnimationFrame": true } } diff --git a/cypress.config.ts b/cypress.config.ts index 11ac2de2..ed8fa795 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b9029673..f8ec366c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index c8b4a6b0..d26bd094 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/block/api.ts b/src/components/block/api.ts index d760ab63..589c6e73 100644 --- a/src/components/block/api.ts +++ b/src/components/block/api.ts @@ -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 * diff --git a/src/components/block/index.ts b/src/components/block/index.ts index b47fe781..80f3d842 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -111,7 +111,6 @@ export default class Block extends EventsDispatcher { 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 { 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 { 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 diff --git a/src/components/core.ts b/src/components/core.ts index 18fe3ff5..65bea1c9 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -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(); diff --git a/src/components/dom.ts b/src/components/dom.ts index 1771c0c3..f7b653cc 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -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; } diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index ee2d00c3..ba5ffbed 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -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(); } diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 7075d30f..ae8e4818 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -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(); } /** diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index c0e552a7..4e0cd039 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -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 diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index de64dc68..0726293c 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -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; } diff --git a/src/components/modules/crossBlockSelection.ts b/src/components/modules/crossBlockSelection.ts index 33da99c8..5807dc0a 100644 --- a/src/components/modules/crossBlockSelection.ts +++ b/src/components/modules/crossBlockSelection.ts @@ -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); } } diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 2324a5d6..bdbec445 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -501,7 +501,6 @@ export default class Paste extends Module { event.preventDefault(); this.processDataTransfer(event.clipboardData); - BlockManager.clearFocused(); Toolbar.close(); }; diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 4cc1db52..24df4447 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -78,6 +78,10 @@ export default class BlockSettings extends Module { */ 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 { /** * 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 { * Close Block Settings pane */ public close(): void { + if (!this.opened) { + return; + } + this.opened = false; /** @@ -163,7 +171,7 @@ export default class BlockSettings extends Module { * 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 */ diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 4d4f656c..b17af30f 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -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 { 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 { $.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 diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 74f637e3..e9288363 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -537,7 +537,7 @@ export default class UI extends Module { 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 { 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 { */ 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 diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 8ba53efb..318275c3 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -154,6 +154,10 @@ export default class Toolbox extends EventsDispatcher { 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; } diff --git a/src/components/utils.ts b/src/components/utils.ts index 6e30817b..a6418a99 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -56,6 +56,7 @@ export const keyCodes = { RIGHT: 39, DELETE: 46, META: 91, + SLASH: 191, }; /** diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index c19ce712..e305afd9 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -212,8 +212,8 @@ export default class Popover extends EventsDispatcher { /** * 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 { 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()) { diff --git a/src/components/utils/popover/search-input.ts b/src/components/utils/popover/search-input.ts index 231743ee..6cf381bb 100644 --- a/src/components/utils/popover/search-input.ts +++ b/src/components/utils/popover/search-input.ts @@ -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); diff --git a/src/styles/block.css b/src/styles/block.css index fb68133e..d4288aae 100644 --- a/src/styles/block.css +++ b/src/styles/block.css @@ -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); - } -} diff --git a/test/cypress/support/index.ts b/test/cypress/support/index.ts index 59ab8f0c..39557412 100644 --- a/test/cypress/support/index.ts +++ b/test/cypress/support/index.ts @@ -8,6 +8,7 @@ import '@cypress/code-coverage/support'; import installLogsCollector from 'cypress-terminal-report/src/installLogsCollector'; +import 'cypress-plugin-tab'; installLogsCollector(); diff --git a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts new file mode 100644 index 00000000..281e3012 --- /dev/null +++ b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts @@ -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'); + }); +}); diff --git a/test/cypress/tests/modules/BlockEvents/Tab.cy.ts b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts new file mode 100644 index 00000000..bb2051cf --- /dev/null +++ b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts @@ -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'); + }); +}); diff --git a/test/cypress/tests/utils/flipper.cy.ts b/test/cypress/tests/utils/flipper.cy.ts index ddd7654c..3ef8b01c 100644 --- a/test/cypress/tests/utils/flipper.cy.ts +++ b/test/cypress/tests/utils/flipper.cy.ts @@ -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 }); diff --git a/types/api/block.d.ts b/types/api/block.d.ts index c20e4622..f44d6738 100644 --- a/types/api/block.d.ts +++ b/types/api/block.d.ts @@ -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 * diff --git a/yarn.lock b/yarn.lock index 10f57abf..f53c136f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"