From 0004b41f8fdb40572ce72f70f6e2e1c4ae32d7fb Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 14 Dec 2023 20:39:07 +0300 Subject: [PATCH 1/8] slash to open toolbox, tab for navigation --- .eslintrc | 3 +- src/components/block/api.ts | 7 ++ src/components/block/index.ts | 9 +- src/components/dom.ts | 12 +- src/components/modules/blockEvents.ts | 122 +++++++++++++------ src/components/modules/blockManager.ts | 1 + src/components/modules/toolbar/index.ts | 20 +-- src/components/modules/ui.ts | 3 +- src/components/utils.ts | 1 + src/components/utils/popover/index.ts | 7 +- src/components/utils/popover/search-input.ts | 6 + types/api/block.d.ts | 5 + 12 files changed, 144 insertions(+), 52 deletions(-) diff --git a/.eslintrc b/.eslintrc index ef5665480..3ee5e6f02 100644 --- a/.eslintrc +++ b/.eslintrc @@ -31,6 +31,7 @@ "ClientRect": true, "ArrayLike": true, "InputEvent": true, - "unknown": true + "unknown": true, + "requestAnimationFrame": true } } diff --git a/src/components/block/api.ts b/src/components/block/api.ts index d760ab63e..589c6e73f 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 b47fe7811..c48a7e528 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -392,13 +392,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; diff --git a/src/components/dom.ts b/src/components/dom.ts index 1771c0c3a..f7b653cca 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 ee2d00c32..ee366410d 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; } } @@ -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 * diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 7075d30fb..051d1e4f2 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -686,6 +686,7 @@ export default class BlockManager extends Module { public clearFocused(): void { this.blocks.forEach((block) => { block.focused = false; + block.selected = false; }); } diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 4d4f656ca..e443f7aa2 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,16 @@ 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'); + + 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 diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 74f637e3c..3da026751 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -249,6 +249,7 @@ export default class UI extends Module { * @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 { 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) { diff --git a/src/components/utils.ts b/src/components/utils.ts index 6e30817b1..a6418a990 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 c19ce7123..a4f95e9f7 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -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 231743ee2..6cf381bb3 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/types/api/block.d.ts b/types/api/block.d.ts index c20e46222..f44d67388 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 * From 25d52b0d46118e5b2487d900f1ca0ab8fc881a67 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 20 Dec 2023 04:05:55 +0300 Subject: [PATCH 2/8] tab, focus improvements - remove "focused" block state - tab navigation respects inputs - allow to focus contentless blocks --- cypress.config.ts | 1 + docs/CHANGELOG.md | 7 +- src/components/block/index.ts | 17 --- src/components/core.ts | 1 - src/components/modules/blockEvents.ts | 92 ++++++---------- src/components/modules/blockManager.ts | 28 ----- src/components/modules/blockSelection.ts | 39 ++++--- src/components/modules/caret.ts | 104 +++++++++++++----- src/components/modules/crossBlockSelection.ts | 5 - src/components/modules/paste.ts | 1 - .../modules/toolbar/blockSettings.ts | 8 +- src/components/modules/ui.ts | 11 -- src/styles/block.css | 7 -- 13 files changed, 145 insertions(+), 176 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index 11ac2de20..ed8fa7959 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 b90296735..155860d9f 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 page input. - `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 that has 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/src/components/block/index.ts b/src/components/block/index.ts index c48a7e528..80f3d8423 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', }; @@ -436,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 18fe3ff58..65bea1c95 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/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index ee366410d..8175b9066 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -93,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); } } @@ -192,49 +191,21 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keydown */ private tabPressed(event: KeyboardEvent): void { - /** - * Clear blocks selection by tab - */ - this.Editor.BlockSelection.clearSelection(); - this.Editor.BlockManager.clearFocused(); + const { InlineToolbar, ConversionToolbar, Caret } = this.Editor; - 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; + const isNavigated = event.shiftKey ? Caret.navigatePrevious(true) : Caret.navigateNext(true); /** - * If we have next Block to focus, then focus it. Otherwise, leave native Tab behaviour + * If we have next Block/input to focus, then focus it. Otherwise, leave native Tab behaviour */ - if (nextBlock !== null) { + if (isNavigated) { 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); - } } } @@ -535,9 +506,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; @@ -556,19 +526,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 */ @@ -594,9 +566,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; @@ -615,19 +586,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 */ @@ -677,7 +650,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 051d1e4f2..ae8e4818c 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -663,33 +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; - block.selected = false; - }); - } - /** * 1) Find first-level Block from passed child Node * 2) Mark it as current @@ -874,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 c0e552a7e..4e0cd0398 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(); - - let block; + const block = BlockManager.getBlockByIndex(index); - 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 de64dc683..8f8891c53 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 { - const { BlockManager } = this.Editor; - const { currentBlock, nextContentfulBlock } = BlockManager; + public navigateNext(force = false): boolean { + const { BlockManager, BlockSelection } = this.Editor; + 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 33da99c88..5807dc0a9 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 2324a5d66..bdbec4458 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 4cc1db521..d1e04d20f 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -104,7 +104,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 +144,10 @@ export default class BlockSettings extends Module { * Close Block Settings pane */ public close(): void { + if (!this.opened) { + return; + } + this.opened = false; /** @@ -163,7 +167,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/ui.ts b/src/components/modules/ui.ts index 3da026751..e92883631 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -249,7 +249,6 @@ export default class UI extends Module { * @type {Element} */ this.nodes.holder = $.getHolder(this.config.holder); - this.nodes.holder.tabIndex = -1; /** * Create and save main UI elements @@ -594,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 */ @@ -692,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/styles/block.css b/src/styles/block.css index fb68133e4..d4288aae6 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); - } -} From 9051ca7483c05e68ae1e0fea559003bd3feb0571 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 20 Dec 2023 04:31:34 +0300 Subject: [PATCH 3/8] fix tests --- src/components/modules/blockEvents.ts | 5 ++++- src/components/modules/caret.ts | 2 +- test/cypress/tests/utils/flipper.cy.ts | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 8175b9066..ba5ffbedd 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -217,7 +217,6 @@ export default class BlockEvents extends Module { return; } - this.Editor.BlockSelection.clearSelection(); this.activateBlockSettings(); } @@ -228,6 +227,10 @@ export default class BlockEvents extends Module { 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 */ diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index 8f8891c53..0726293c3 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -433,7 +433,7 @@ export default class Caret extends Module { * @param {boolean} force - pass true to skip check for caret position */ public navigateNext(force = false): boolean { - const { BlockManager, BlockSelection } = this.Editor; + const { BlockManager } = this.Editor; const { currentBlock, nextBlock } = BlockManager; const { nextInput } = currentBlock; const isAtEnd = this.isAtEnd; diff --git a/test/cypress/tests/utils/flipper.cy.ts b/test/cypress/tests/utils/flipper.cy.ts index ddd7654c3..3ef8b01c6 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 }); From 9bb0b0c111a7c472c9628f80ed04fe4343096a98 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 20 Dec 2023 22:38:22 +0300 Subject: [PATCH 4/8] tests for Slash --- docs/CHANGELOG.md | 4 +- .../modules/toolbar/blockSettings.ts | 4 + src/components/ui/toolbox.ts | 4 + src/components/utils/popover/index.ts | 8 +- .../tests/modules/BlockEvents/Slash.cy.ts | 87 +++++++++++++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 test/cypress/tests/modules/BlockEvents/Slash.cy.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 155860d9f..f8ec366c6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,7 +5,7 @@ - `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 page input. +- `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 @@ -14,7 +14,7 @@ - `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 -- `Improvement` — Now you can set focus via arrows/Tab to "contentless" (decorative) blocks like Delimiter that has no inputs. +- `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. diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index d1e04d20f..24df44475 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'); + } } /** diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 8ba53efb3..318275c3e 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/popover/index.ts b/src/components/utils/popover/index.ts index a4f95e9f7..f016d803d 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; } /** @@ -286,6 +286,10 @@ export default class Popover extends EventsDispatcher { private make(): void { this.nodes.popover = Dom.make('div', [ Popover.CSS.popover ]); + if (import.meta.env.MODE === 'test') { + this.nodes.popover.setAttribute('data-cy', 'popover'); + } + this.nodes.nothingFoundMessage = Dom.make('div', [ Popover.CSS.nothingFoundMessage ], { textContent: this.messages.nothingFound, }); 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 000000000..ef11bc072 --- /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('[data-cy="popover"]') + .should('be.visible'); + }); + }); + + describe('pressed in non-empty block', function () { + it('should not open Toolbox', () => { + 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('[data-cy="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('[data-cy="popover"]') + .should('be.visible'); + }); +}); From 788b4fbd9577df281c9d1e1009479ea275aa67f1 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 20 Dec 2023 23:38:48 +0300 Subject: [PATCH 5/8] tab tests --- .../tests/modules/BlockEvents/Tab.cy.ts | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 test/cypress/tests/modules/BlockEvents/Tab.cy.ts 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 000000000..d4eb9c9b3 --- /dev/null +++ b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts @@ -0,0 +1,317 @@ +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; + } +} + +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(100); + + 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(100); + + 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(100); + + cy.get('[data-cy=contentless-tool]') + .parents('.ce-block') + .should('have.class', 'ce-block--selected'); + }); +}); + +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(100); + + 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(100); + + 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(100); + + cy.get('[data-cy=contentless-tool]') + .parents('.ce-block') + .should('have.class', 'ce-block--selected'); + }); +}); From 75625d7d07b9ed92e2d26a57cd39d45446f850ef Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 20 Dec 2023 23:59:15 +0300 Subject: [PATCH 6/8] test for tabbing out of editor --- package.json | 1 + test/cypress/support/index.ts | 1 + .../tests/modules/BlockEvents/Slash.cy.ts | 2 +- .../tests/modules/BlockEvents/Tab.cy.ts | 48 +++++++++++++++++++ yarn.lock | 25 ++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c8b4a6b0f..d26bd0943 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/test/cypress/support/index.ts b/test/cypress/support/index.ts index 59ab8f0c4..395574124 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 index ef11bc072..173fb7e8c 100644 --- a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts @@ -26,7 +26,7 @@ describe('Slash keydown', function () { }); describe('pressed in non-empty block', function () { - it('should not open Toolbox', () => { + it('should not open Toolbox and just add the / char', () => { cy.createEditor({ data: { blocks: [ diff --git a/test/cypress/tests/modules/BlockEvents/Tab.cy.ts b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts index d4eb9c9b3..f365dba79 100644 --- a/test/cypress/tests/modules/BlockEvents/Tab.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts @@ -173,6 +173,30 @@ describe('Tab keydown', function () { .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 () { @@ -314,4 +338,28 @@ describe('Shift+Tab keydown', function () { .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/yarn.lock b/yarn.lock index 10f57abf4..f53c136f9 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" From 51d37c751f3b7a0a1a0ffce976c3b51de98dd3f4 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 21 Dec 2023 22:12:43 +0300 Subject: [PATCH 7/8] tests fixed --- src/components/utils/popover/index.ts | 4 ---- test/cypress/tests/modules/BlockEvents/Slash.cy.ts | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index f016d803d..e305afd97 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -286,10 +286,6 @@ export default class Popover extends EventsDispatcher { private make(): void { this.nodes.popover = Dom.make('div', [ Popover.CSS.popover ]); - if (import.meta.env.MODE === 'test') { - this.nodes.popover.setAttribute('data-cy', 'popover'); - } - this.nodes.nothingFoundMessage = Dom.make('div', [ Popover.CSS.nothingFoundMessage ], { textContent: this.messages.nothingFound, }); diff --git a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts index 173fb7e8c..281e30123 100644 --- a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts @@ -20,7 +20,7 @@ describe('Slash keydown', function () { .type('/'); cy.get('[data-cy="toolbox"]') - .get('[data-cy="popover"]') + .get('.ce-popover') .should('be.visible'); }); }); @@ -46,7 +46,7 @@ describe('Slash keydown', function () { .type('/'); cy.get('[data-cy="toolbox"]') - .get('[data-cy="popover"]') + .get('.ce-popover') .should('not.be.visible'); /** @@ -81,7 +81,7 @@ describe('CMD+Slash keydown', function () { .type('{cmd}/'); cy.get('[data-cy="block-tunes"]') - .get('[data-cy="popover"]') + .get('.ce-popover') .should('be.visible'); }); }); From 05cfca344a00308758a6d8ca61da35c702934425 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Fri, 22 Dec 2023 23:11:35 +0300 Subject: [PATCH 8/8] review fixes --- src/components/modules/toolbar/index.ts | 3 ++- .../cypress/tests/modules/BlockEvents/Tab.cy.ts | 17 +++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index e443f7aa2..b17af30f8 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -413,8 +413,9 @@ export default class Toolbar extends Module { $.append(this.nodes.actions, this.nodes.settingsToggler); const blockTunesTooltip = $.make('div'); + const blockTunesTooltipEl = $.text(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune')); - blockTunesTooltip.appendChild(document.createTextNode(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'))); + blockTunesTooltip.appendChild(blockTunesTooltipEl); blockTunesTooltip.appendChild($.make('div', this.CSS.plusButtonShortcut, { textContent: beautifyShortcut('CMD + /'), })); diff --git a/test/cypress/tests/modules/BlockEvents/Tab.cy.ts b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts index f365dba79..bb2051cf6 100644 --- a/test/cypress/tests/modules/BlockEvents/Tab.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Tab.cy.ts @@ -43,6 +43,11 @@ class ContentlessTool extends ToolMock { } } +/** + * 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({ @@ -69,7 +74,7 @@ describe('Tab keydown', function () { .first() .click() .trigger('keydown', { keyCode: 9 }) - .wait(100); + .wait(CARET_MOVE_TIME); cy.get('[data-cy=editorjs]') .find('.ce-paragraph') @@ -115,7 +120,7 @@ describe('Tab keydown', function () { .first() .click() .trigger('keydown', { keyCode: 9 }) - .wait(100); + .wait(CARET_MOVE_TIME); cy.get('[data-cy=tool-with-two-inputs]') .find('[contenteditable=true]') @@ -167,7 +172,7 @@ describe('Tab keydown', function () { .first() .click() .trigger('keydown', { keyCode: 9 }) - .wait(100); + .wait(CARET_MOVE_TIME); cy.get('[data-cy=contentless-tool]') .parents('.ce-block') @@ -228,7 +233,7 @@ describe('Shift+Tab keydown', function () { keyCode: 9, shiftKey: true, }) - .wait(100); + .wait(CARET_MOVE_TIME); cy.get('[data-cy=editorjs]') .find('.ce-paragraph') @@ -277,7 +282,7 @@ describe('Shift+Tab keydown', function () { keyCode: 9, shiftKey: true, }) - .wait(100); + .wait(CARET_MOVE_TIME); cy.get('[data-cy=tool-with-two-inputs]') .find('[contenteditable=true]') @@ -332,7 +337,7 @@ describe('Shift+Tab keydown', function () { keyCode: 9, shiftKey: true, }) - .wait(100); + .wait(CARET_MOVE_TIME); cy.get('[data-cy=contentless-tool]') .parents('.ce-block')