This commit is contained in:
GuillaumeOnepilot 2023-12-25 19:06:05 +01:00
commit 292d9f86f0
45 changed files with 1099 additions and 331 deletions

View file

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

View file

@ -11,6 +11,8 @@ jobs:
check-for-no-version-changing:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
actions: write
steps:
# Checkout to target branch
- uses: actions/checkout@v2
@ -32,16 +34,22 @@ jobs:
uses: codex-team/action-nodejs-package-info@v1
# Stop workflow and do not bump version if it was changed already
- name: Stop workflow and do not bump version if it was changed already
uses: actions/github-script@v3
- name: Stop workflow if version was changed already
if: steps.packageOld.outputs.version != steps.packageNew.outputs.version
with:
script: |
core.setFailed('Version was changed! ${{ steps.packageOld.outputs.version }} -> ${{ steps.packageNew.outputs.version }}')
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel
bump-version:
needs: check-for-no-version-changing
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
# Checkout to target branch
- uses: actions/checkout@v2

View file

@ -11,6 +11,8 @@ jobs:
check-version-changing:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- uses: actions/setup-node@v3
with:
@ -36,16 +38,21 @@ jobs:
# Stop workflow if version was not changed
- name: Stop workflow if version was not changed
uses: actions/github-script@v3
if: steps.packageOld.outputs.version == steps.packageNew.outputs.version
with:
script: |
core.setFailed('No version changes. ${{ steps.packageOld.outputs.version }}')
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel
# Create a new draft release
release-draft:
needs: check-version-changing
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# Checkout to target branch
- uses: actions/checkout@v2
@ -118,4 +125,4 @@ jobs:
webhook: ${{ secrets.CODEX_BOT_WEBHOOK_FRONTEND }}
message: '🦥 [Draft release v${{ steps.package.outputs.version }}](${{ steps.create_release.outputs.html_url }}) for package [${{ steps.package.outputs.name }}](${{ steps.package.outputs.npmjs-link }}) has been created. Add changelog and publish it!'
parse_mode: 'markdown'
disable_web_page_preview: true
disable_web_page_preview: true

View file

@ -18,4 +18,4 @@ jobs:
with:
config: video=false
browser: ${{ matrix.browser }}
build: yarn build
build: yarn build:test

View file

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

View file

@ -3,13 +3,21 @@
### 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
- `Fix` — When the focusing Block is out of the viewport, the page will be scrolled.
- `Fix` - Compiler error "This import is never used as a value and must use 'import type'..." fixed
- `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` — Caret losing on Mobile Devices when adding a block via Toolbox or via Backspace at the beginning of a Block
- `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

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.29.0-rc.6",
"version": "2.29.0-rc.8",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
@ -14,7 +14,8 @@
],
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build --mode production",
"build:test": "vite build --mode test",
"lint": "eslint src/ --ext .ts && yarn lint:tests",
"lint:errors": "eslint src/ --ext .ts --quiet",
"lint:fix": "eslint src/ --ext .ts --fix",
@ -26,8 +27,8 @@
"_tools:build": "git submodule foreach yarn build",
"_tools:make": "yarn _tools:yarn && yarn _tools:build",
"tools:update": "yarn _tools:checkout && yarn _tools:pull && yarn _tools:make",
"test:e2e": "yarn build && cypress run",
"test:e2e:open": "yarn build && cypress open",
"test:e2e": "yarn build:test && cypress run",
"test:e2e:open": "yarn build:test && cypress open",
"devserver:start": "yarn build && node ./devserver.js"
},
"author": "CodeX",
@ -53,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",

View file

@ -84,6 +84,13 @@ function BlockAPI(
return block.stretched;
},
/**
* True if Block has inputs to be focused
*/
get focusable(): boolean {
return block.focusable;
},
/**
* Call Tool method with errors handler under-the-hood
*

View file

@ -111,7 +111,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
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<BlockEvents> {
return _.isFunction(this.toolInstance.merge);
}
/**
* If Block contains inputs, it is focusable
*/
public get focusable(): boolean {
return this.inputs.length !== 0;
}
/**
* Check block for emptiness
*
* @returns {boolean}
*/
public get isEmpty(): boolean {
const emptyText = $.isEmpty(this.pluginsContent);
const emptyText = $.isEmpty(this.pluginsContent, '/');
const emptyMedia = !this.hasMedia;
return emptyText && emptyMedia;
@ -429,22 +435,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
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

View file

@ -64,7 +64,6 @@ export default class Core {
try {
if ((this.configuration as EditorConfig).autofocus) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
BlockManager.highlightCurrentNode();
}
} catch {}

View file

@ -348,9 +348,10 @@ export default class Dom {
* @description Method checks simple Node without any childs for emptiness
* If you have Node with 2 or more children id depth, you better use {@link Dom#isEmpty} method
* @param {Node} node - node to check
* @param {string} [ignoreChars] - char or substring to treat as empty
* @returns {boolean} true if it is empty
*/
public static isNodeEmpty(node: Node): boolean {
public static isNodeEmpty(node: Node, ignoreChars?: string): boolean {
let nodeText;
if (this.isSingleTag(node as HTMLElement) && !this.isLineBreakTag(node as HTMLElement)) {
@ -363,6 +364,10 @@ export default class Dom {
nodeText = node.textContent.replace('\u200B', '');
}
if (ignoreChars) {
nodeText = nodeText.replace(new RegExp(ignoreChars, 'g'), '');
}
return nodeText.trim().length === 0;
}
@ -386,9 +391,10 @@ export default class Dom {
*
* @description Pushes to stack all DOM leafs and checks for emptiness
* @param {Node} node - node to check
* @param {string} [ignoreChars] - char or substring to treat as empty
* @returns {boolean}
*/
public static isEmpty(node: Node): boolean {
public static isEmpty(node: Node, ignoreChars?: string): boolean {
/**
* Normalize node to merge several text nodes to one to reduce tree walker iterations
*/
@ -403,7 +409,7 @@ export default class Dom {
continue;
}
if (this.isLeaf(node) && !this.isNodeEmpty(node)) {
if (this.isLeaf(node) && !this.isNodeEmpty(node, ignoreChars)) {
return false;
}

View file

@ -52,6 +52,13 @@ export default class BlockEvents extends Module {
case _.keyCodes.TAB:
this.tabPressed(event);
break;
case _.keyCodes.SLASH:
if (event.ctrlKey || event.metaKey) {
this.commandSlashPressed();
} else {
this.slashPressed();
}
break;
}
}
@ -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();
}

View file

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

View file

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

View file

@ -11,7 +11,6 @@ import Selection from '../selection';
import Module from '../__module';
import Block from '../block';
import $ from '../dom';
import * as _ from '../utils';
/**
* @typedef {Caret} Caret
@ -46,8 +45,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 +146,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 +241,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) {
@ -255,13 +296,7 @@ export default class Caret extends Module {
break;
}
/**
* @todo try to fix via Promises or use querySelectorAll to not to use timeout
*/
_.delay(() => {
this.set(nodeToSet as HTMLElement, offset);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
this.set(nodeToSet as HTMLElement, offset);
BlockManager.setCurrentBlockByChildNode(block.holder);
BlockManager.currentBlock.currentInput = element;
@ -388,17 +423,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 +452,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 +460,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 +477,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;
}

View file

@ -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);
}
}

View file

@ -501,7 +501,6 @@ export default class Paste extends Module {
event.preventDefault();
this.processDataTransfer(event.clipboardData);
BlockManager.clearFocused();
Toolbar.close();
};

View file

@ -78,6 +78,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
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<BlockSettingsNodes> {
/**
* 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<BlockSettingsNodes> {
* Close Block Settings pane
*/
public close(): void {
if (!this.opened) {
return;
}
this.opened = false;
/**
@ -163,7 +171,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* 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 */

View file

@ -9,6 +9,7 @@ import Block from '../../block';
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
import { IconMenu, IconPlus } from '@codexteam/icons';
import { BlockHovered } from '../../events/BlockHovered';
import { beautifyShortcut } from '../../utils';
/**
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
@ -392,7 +393,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
tooltipContent.appendChild(document.createTextNode(I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Add')));
tooltipContent.appendChild($.make('div', this.CSS.plusButtonShortcut, {
textContent: '⇥ Tab',
textContent: '/',
}));
tooltip.onHover(this.nodes.plusButton, tooltipContent, {
@ -411,13 +412,17 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.actions, this.nodes.settingsToggler);
tooltip.onHover(
this.nodes.settingsToggler,
I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'),
{
hidingDelay: 400,
}
);
const blockTunesTooltip = $.make('div');
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

View file

@ -138,15 +138,16 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Avoid to use it just for closing IT, better call .close() clearly.
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public tryToShow(needToClose = false, needToShowConversionToolbar = true): void {
if (!this.allowedToShow()) {
if (needToClose) {
this.close();
}
public async tryToShow(needToClose = false, needToShowConversionToolbar = true): Promise<void> {
if (needToClose) {
this.close();
}
if (!this.allowedToShow()) {
return;
}
await this.addToolsFiltered(needToShowConversionToolbar);
this.move();
this.open(needToShowConversionToolbar);
this.Editor.Toolbar.close();
@ -187,51 +188,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.Editor.ConversionToolbar.close();
}
/**
* Shows Inline Toolbar
*
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public open(needToShowConversionToolbar = true): void {
if (this.opened) {
return;
}
/**
* Filter inline-tools and show only allowed by Block's Tool
*/
this.addToolsFiltered();
/**
* Show Inline Toolbar
*/
this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed);
this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`);
this.opened = true;
if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) {
/**
* Change Conversion Dropdown content for current tool
*/
this.setConversionTogglerContent();
} else {
/**
* hide Conversion Dropdown with there are no tools
*/
this.nodes.conversionToggler.hidden = true;
}
/**
* Get currently visible buttons to pass it to the Flipper
*/
let visibleTools = Array.from(this.buttonsList);
visibleTools.unshift(this.nodes.conversionToggler);
visibleTools = visibleTools.filter((tool) => !(tool as HTMLElement).hidden);
this.flipper.activate(visibleTools as HTMLElement[]);
}
/**
* Check if node is contained by Inline Toolbar
*
@ -268,6 +224,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.CSS.inlineToolbar,
...(this.isRtl ? [ this.Editor.UI.CSS.editorRtlFix ] : []),
]);
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'inline-toolbar');
}
/**
* Creates a different wrapper for toggler and buttons.
*/
@ -327,6 +288,33 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.enableFlipper();
}
/**
* Shows Inline Toolbar
*/
private open(): void {
if (this.opened) {
return;
}
/**
* Show Inline Toolbar
*/
this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed);
this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`);
this.opened = true;
/**
* Get currently visible buttons to pass it to the Flipper
*/
let visibleTools = Array.from(this.buttonsList);
visibleTools.unshift(this.nodes.conversionToggler);
visibleTools = visibleTools.filter((tool) => !(tool as HTMLElement).hidden);
this.flipper.activate(visibleTools as HTMLElement[]);
}
/**
* Move Toolbar to the selected text
*/
@ -334,7 +322,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
const selectionRect = SelectionUtils.rect as DOMRect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.left,
x: selectionRect.x - wrapperOffset.x,
y: selectionRect.y +
selectionRect.height -
// + window.scrollY
@ -342,34 +330,15 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.toolbarVerticalMargin,
};
const realRightCoord = newCoords.x + this.width + wrapperOffset.x;
/**
* If we know selections width, place InlineToolbar to center
* Prevent InlineToolbar from overflowing the content zone on the right side
*/
if (selectionRect.width) {
newCoords.x += Math.floor(selectionRect.width / 2);
if (realRightCoord > this.Editor.UI.contentRect.right) {
newCoords.x = this.Editor.UI.contentRect.right - this.width - wrapperOffset.x;
}
/**
* Inline Toolbar has -50% translateX, so we need to check real coords to prevent overflowing
*/
const realLeftCoord = newCoords.x - this.width / 2;
const realRightCoord = newCoords.x + this.width / 2;
/**
* By default, Inline Toolbar has top-corner at the center
* We are adding a modifiers for to move corner to the left or right
*/
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarLeftOriented,
realLeftCoord < this.Editor.UI.contentRect.left
);
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarRightOriented,
realRightCoord > this.Editor.UI.contentRect.right
);
this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px';
this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px';
}
@ -529,8 +498,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Append only allowed Tools
*
* @param {boolean} needToShowConversionToolbar - pass false to not to show Conversion Toolbar (e.g. for Footnotes-like tools)
*/
private addToolsFiltered(): void {
private async addToolsFiltered(needToShowConversionToolbar = true): Promise<void> {
const currentSelection = SelectionUtils.get();
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
@ -545,6 +516,18 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.addTool(tool);
});
if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) {
/**
* Change Conversion Dropdown content for current tool
*/
await this.setConversionTogglerContent();
} else {
/**
* hide Conversion Dropdown with there are no tools
*/
this.nodes.conversionToggler.hidden = true;
}
/**
* Recalculate width because some buttons can be hidden
*/

View file

@ -350,9 +350,12 @@ export default class UI extends Module<UINodes> {
/**
* Handle selection change to manipulate Inline Toolbar appearance
*/
this.readOnlyMutableListeners.on(document, 'selectionchange', () => {
const selectionChangeDebounceTimeout = 180;
const selectionChangeDebounced = _.debounce(() => {
this.selectionChanged();
}, true);
}, selectionChangeDebounceTimeout);
this.readOnlyMutableListeners.on(document, 'selectionchange', selectionChangeDebounced, true);
this.readOnlyMutableListeners.on(window, 'resize', () => {
this.resizeDebouncer();
@ -534,7 +537,7 @@ export default class UI extends Module<UINodes> {
if (this.Editor.Toolbar.toolbox.opened) {
this.Editor.Toolbar.toolbox.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock, this.Editor.Caret.positions.END);
} else if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else if (this.Editor.ConversionToolbar.opened) {
@ -590,11 +593,6 @@ export default class UI extends Module<UINodes> {
this.Editor.Caret.setToBlock(newBlock);
/**
* And highlight
*/
this.Editor.BlockManager.highlightCurrentNode();
/**
* Move toolbar and show plus button because new Block is empty
*/
@ -688,11 +686,6 @@ export default class UI extends Module<UINodes> {
*/
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
@ -860,9 +853,6 @@ export default class UI extends Module<UINodes> {
const isNeedToShowConversionToolbar = clickedOutsideBlockContent !== true;
/**
* @todo add debounce
*/
this.Editor.InlineToolbar.tryToShow(true, isNeedToShowConversionToolbar);
}
}

View file

@ -154,6 +154,10 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
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;
}

View file

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

View file

@ -212,8 +212,8 @@ export default class Popover extends EventsDispatcher<PopoverEventMap> {
/**
* 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<PopoverEventMap> {
this.flipper.activate(this.flippableElements);
if (this.search !== undefined) {
setTimeout(() => {
this.search.focus();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 100);
requestAnimationFrame(() => {
this.search?.focus();
});
}
if (isMobileScreen()) {

View file

@ -120,6 +120,12 @@ export default class SearchInput {
this.input = Dom.make('input', SearchInput.CSS.input, {
placeholder,
/**
* Used to prevent focusing on the input by Tab key
* (Popover in the Toolbar lays below the blocks,
* so Tab in the last block will focus this hidden input if this property is not set)
*/
tabIndex: -1,
}) as HTMLInputElement;
this.wrapper.appendChild(iconWrapper);

11
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
interface ImportMetaEnv {
/**
* Build environment.
* For example, used to detect building for tests and add "data-cy" attributes for DOM querying.
*/
readonly MODE: "test" | "development" | "production";
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -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);
}
}

View file

@ -2,11 +2,10 @@
--y-offset: 8px;
@apply --overlay-pane;
transform: translateX(-50%) translateY(8px) scale(0.94);
opacity: 0;
visibility: hidden;
transition: transform 150ms ease, opacity 250ms ease;
will-change: transform, opacity;
transition: opacity 250ms ease;
will-change: opacity, left, top;
top: 0;
left: 0;
z-index: 3;
@ -14,24 +13,6 @@
&--showed {
opacity: 1;
visibility: visible;
transform: translateX(-50%)
}
&--left-oriented {
transform: translateX(-23px) translateY(8px) scale(0.94);
}
&--left-oriented&--showed {
transform: translateX(-23px);
}
&--right-oriented {
transform: translateX(-100%) translateY(8px) scale(0.94);
margin-left: 23px;
}
&--right-oriented&--showed {
transform: translateX(-100%);
}
[hidden] {

1
src/tools/paragraph Submodule

@ -0,0 +1 @@
Subproject commit 6e45413ccdfd021f1800eb6e5bf7440184d5ab7c

View file

@ -155,3 +155,82 @@ Cypress.Commands.add('selectText', {
return cy.wrap(subject);
});
/**
* Select element's text by offset
* Note. Previous subject should have 'textNode' as firstChild
*
* Usage
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .selectTextByOffset([0, 5])
*
* @param offset - offset to select
*/
Cypress.Commands.add('selectTextByOffset', {
prevSubject: true,
}, (subject, offset: [number, number]) => {
const el = subject[0];
const document = el.ownerDocument;
const range = document.createRange();
const textNode = el.firstChild;
const selectionPositionStart = offset[0];
const selectionPositionEnd = offset[1];
range.setStart(textNode, selectionPositionStart);
range.setEnd(textNode, selectionPositionEnd);
document.getSelection().removeAllRanges();
document.getSelection().addRange(range);
return cy.wrap(subject);
});
/**
* Returns line wrap positions for passed element
*
* Usage
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .getLineWrapPositions()
*
* @returns number[] - array of line wrap positions
*/
Cypress.Commands.add('getLineWrapPositions', {
prevSubject: true,
}, (subject) => {
const element = subject[0];
const document = element.ownerDocument;
const text = element.textContent;
const lineWraps = [];
let currentLineY = 0;
/**
* Iterate all chars in text, create range for each char and get its position
*/
for (let i = 0; i < text.length; i++) {
const range = document.createRange();
range.setStart(element.firstChild, i);
range.setEnd(element.firstChild, i);
const rect = range.getBoundingClientRect();
if (i === 0) {
currentLineY = rect.top;
continue;
}
/**
* If current char Y position is higher than previously saved line Y, that means a line wrap
*/
if (rect.top > currentLineY) {
lineWraps.push(i);
currentLineY = rect.top;
}
}
return cy.wrap(lineWraps);
});

View file

@ -60,6 +60,31 @@ declare global {
* @param text - text to select
*/
selectText(text: string): Chainable<Subject>;
/**
* Select element's text by offset
* Note. Previous subject should have 'textNode' as firstChild
*
* Usage
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .selectTextByOffset([0, 5])
*
* @param offset - offset to select
*/
selectTextByOffset(offset: [number, number]): Chainable<Subject>;
/**
* Returns line wrap positions for passed element
*
* Usage
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .getLineWrapPositions()
*
* @returns number[] - array of line wrap positions
*/
getLineWrapPositions(): Chainable<number[]>;
}
interface ApplicationWindow {

View file

@ -8,6 +8,7 @@
import '@cypress/code-coverage/support';
import installLogsCollector from 'cypress-terminal-report/src/installLogsCollector';
import 'cypress-plugin-tab';
installLogsCollector();

View file

@ -17,6 +17,7 @@ describe('Inline Tool Link', () => {
.find('div.ce-block')
.click()
.type('{selectall}')
.wait(200)
.type('{ctrl}K');
cy.get('[data-cy=editorjs]')
@ -30,4 +31,44 @@ describe('Inline Tool Link', () => {
.find('a')
.should('have.attr', 'href', 'https://codex.so');
});
it('should remove fake background on selection change', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'First block text',
},
},
{
type: 'paragraph',
data: {
text: 'Second block text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.first()
.click()
.type('{selectall}')
.wait(200)
.type('{ctrl}K');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.click()
.type('{selectall}')
.wait(200);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph span[style]')
.should('not.exist');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -0,0 +1,76 @@
describe('Inline Toolbar', () => {
it('should appear aligned with left coord of selection rect', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'First block text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.selectText('block');
cy.get('[data-cy="inline-toolbar"]')
.should('be.visible')
.then(($toolbar) => {
const editorWindow = $toolbar.get(0).ownerDocument.defaultView;
const selection = editorWindow.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
expect($toolbar.offset().left).to.closeTo(rect.left, 1);
});
});
it('should appear aligned with right side of text column when toolbar\'s width is not fit at right', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor.',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.as('blockWrapper')
.getLineWrapPositions()
.then((lineWrapIndexes) => {
const firstLineWrapIndex = lineWrapIndexes[0];
/**
* Select last 5 chars of the first line
*/
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.selectTextByOffset([firstLineWrapIndex - 5, firstLineWrapIndex - 1]);
});
cy.get('[data-cy="inline-toolbar"]')
.should('be.visible')
.then(($toolbar) => {
cy.get('@blockWrapper')
.then(($blockWrapper) => {
const blockWrapperRect = $blockWrapper.get(0).getBoundingClientRect();
/**
* Toolbar should be aligned with right side of text column
*/
expect($toolbar.offset().left + $toolbar.width()).to.closeTo(blockWrapperRect.right, 3);
});
});
});
});

View file

@ -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,14 +63,16 @@ describe('Flipper', () => {
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
.as('pluginInput')
.focus()
.type(sampleText);
.type(sampleText)
.wait(100);
// Try to delete the block via keyboard
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 });

View file

@ -35,6 +35,11 @@ export interface BlockAPI {
*/
readonly selected: boolean;
/**
* True if Block has inputs to be focused
*/
readonly focusable: boolean;
/**
* Setter sets Block's stretch state
*

View file

@ -1,4 +1,4 @@
import { BlockAPI } from '../../api';
import type { BlockAPI } from '../../api';
/**
* Details of CustomEvent fired on block mutation

View file

@ -1,4 +1,4 @@
import { BlockMutationEventDetail } from './Base';
import type { BlockMutationEventDetail } from './Base';
/**
* Type name of CustomEvent related to block added event

View file

@ -1,4 +1,4 @@
import { BlockMutationEventDetail } from './Base';
import type { BlockMutationEventDetail } from './Base';
/**
* Type name of CustomEvent related to block changed event

View file

@ -1,4 +1,4 @@
import { BlockMutationEventDetail } from './Base';
import type { BlockMutationEventDetail } from './Base';
/**
* Type name of CustomEvent related to block moved event

View file

@ -1,4 +1,4 @@
import { BlockMutationEventDetail } from './Base';
import type { BlockMutationEventDetail } from './Base';
/**
* Type name of CustomEvent related to block removed event

View file

@ -1,7 +1,7 @@
import { BlockAddedEvent, BlockAddedMutationType } from './BlockAdded';
import { BlockChangedEvent, BlockChangedMutationType } from './BlockChanged';
import { BlockMovedEvent, BlockMovedMutationType } from './BlockMoved';
import { BlockRemovedEvent, BlockRemovedMutationType } from './BlockRemoved';
import { type BlockAddedEvent, BlockAddedMutationType } from './BlockAdded';
import { type BlockChangedEvent, BlockChangedMutationType } from './BlockChanged';
import { type BlockMovedEvent, BlockMovedMutationType } from './BlockMoved';
import { type BlockRemovedEvent, BlockRemovedMutationType } from './BlockRemoved';
/**
* Map for Custom Events related to block mutation types

View file

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