feat(block-events): delete keydown handling added (#2402)

* feat(block-events): delete keydown logic added

* chore(ui-events): delete keydown will remove selected blocks

* backspace tests added

* delete keydown tests

* tests for ui added

* fix ci

* separate block events tests for several files

* Delete BlockEvents.cy.ts

* rm unused change

* add code-review fixes + corner cases handling

* Update blockEvents.ts

* allow merging only same type blocks
This commit is contained in:
Peter Savchenko 2023-07-06 23:45:12 +03:00 committed by GitHub
parent 9e64003c0a
commit 2ab9eb13bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1120 additions and 167 deletions

View file

@ -34,6 +34,7 @@
"textareas",
"twitterwidget",
"typeof",
"Unmergeable",
"viewports"
]
}

View file

@ -1,5 +1,12 @@
# Changelog
### 2.28.0
- `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one.
- `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected.
- `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of current.
### 2.27.2
- `Fix` - `onChange` won't be called when element with data-mutation-free changes some attribute

@ -1 +1 @@
Subproject commit 3cc506758440ac3f1bc83008a6ef75813b6386c3
Subproject commit 605a73d2b7bec6438c7c0d5ab09eae86b5e9212e

View file

@ -137,9 +137,11 @@ export default class BlocksAPI extends Module {
*
* @param {number} blockIndex - index of Block to delete
*/
public delete(blockIndex?: number): void {
public delete(blockIndex: number = this.Editor.BlockManager.currentBlockIndex): void {
try {
this.Editor.BlockManager.removeBlock(blockIndex);
const block = this.Editor.BlockManager.getBlockByIndex(blockIndex);
this.Editor.BlockManager.removeBlock(block);
} catch (e) {
_.logLabeled(e, 'warn');

View file

@ -5,6 +5,8 @@ import Module from '../__module';
import * as _ from '../utils';
import SelectionUtils from '../selection';
import Flipper from '../flipper';
import type Block from '../block';
import { areBlocksMergeable } from '../utils/blocks';
/**
*
@ -29,6 +31,10 @@ export default class BlockEvents extends Module {
this.backspace(event);
break;
case _.keyCodes.DELETE:
this.delete(event);
break;
case _.keyCodes.ENTER:
this.enter(event);
break;
@ -112,7 +118,7 @@ export default class BlockEvents extends Module {
*
* @param {KeyboardEvent} event - tab keydown event
*/
public tabPressed(event): void {
public tabPressed(event: KeyboardEvent): void {
/**
* Clear blocks selection by tab
*/
@ -277,104 +283,176 @@ export default class BlockEvents extends Module {
* @param {KeyboardEvent} event - keydown
*/
private backspace(event: KeyboardEvent): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const currentBlock = BlockManager.currentBlock;
const tool = currentBlock.tool;
const { BlockManager, Caret } = this.Editor;
const { currentBlock, previousBlock } = BlockManager;
/**
* Check if Block should be removed by current Backspace keydown
* If some fragment is selected, leave native behaviour
*/
if (currentBlock.selected || (currentBlock.isEmpty && currentBlock.currentInput === currentBlock.firstInput)) {
event.preventDefault();
if (!SelectionUtils.isCollapsed) {
return;
}
const index = BlockManager.currentBlockIndex;
/**
* If caret is not at the start, leave native behaviour
*/
if (!Caret.isAtStart) {
return;
}
/**
* All the cases below have custom behaviour, so we don't need a native one
*/
event.preventDefault();
this.Editor.Toolbar.close();
if (BlockManager.previousBlock && BlockManager.previousBlock.inputs.length === 0) {
/** If previous block doesn't contain inputs, remove it */
BlockManager.removeBlock(index - 1);
} else {
/** If block is empty, just remove it */
BlockManager.removeBlock();
}
const isFirstInputFocused = currentBlock.currentInput === currentBlock.firstInput;
Caret.setToBlock(
BlockManager.currentBlock,
index ? Caret.positions.END : Caret.positions.START
);
/** Close Toolbar */
this.Editor.Toolbar.close();
/** Clear selection */
BlockSelection.clearSelection(event);
/**
* For example, caret at the start of the Quote second input (caption) just navigate previous input
*/
if (!isFirstInputFocused) {
Caret.navigatePrevious();
return;
}
/**
* Don't handle Backspaces when Tool sets enableLineBreaks to true.
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
*
* But if caret is at start of the block, we allow to remove it by backspaces
* Backspace at the start of the first Block should do nothing
*/
if (tool.isLineBreaksEnabled && !Caret.isAtStart) {
if (previousBlock === null) {
return;
}
const isFirstBlock = BlockManager.currentBlockIndex === 0;
const canMergeBlocks = Caret.isAtStart &&
SelectionUtils.isCollapsed &&
currentBlock.currentInput === currentBlock.firstInput &&
!isFirstBlock;
/**
* If prev Block is empty, it should be removed just like a character
*/
if (previousBlock.isEmpty) {
BlockManager.removeBlock(previousBlock);
if (canMergeBlocks) {
/**
* preventing browser default behaviour
*/
event.preventDefault();
return;
}
/**
* Merge Blocks
*/
this.mergeBlocks();
/**
* If current Block is empty, just remove it and set cursor to the previous Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
const newCurrentBlock = BlockManager.currentBlock;
Caret.setToBlock(newCurrentBlock, Caret.positions.END);
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, previousBlock);
/**
* If Blocks could be merged, do it
* Otherwise, just navigate previous block
*/
if (bothBlocksMergeable) {
this.mergeBlocks(previousBlock, currentBlock);
} else {
Caret.setToBlock(previousBlock, Caret.positions.END);
}
}
/**
* Merge current and previous Blocks if they have the same type
* Handles delete keydown on Block
* Removes char after the caret.
* If caret is at the end of the block, merge next block with current
*
* @param {KeyboardEvent} event - keydown
*/
private mergeBlocks(): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
const targetBlock = BlockManager.previousBlock;
const blockToMerge = BlockManager.currentBlock;
private delete(event: KeyboardEvent): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock, nextBlock } = BlockManager;
/**
* Blocks that can be merged:
* 1) with the same Name
* 2) Tool has 'merge' method
*
* other case will handle as usual ARROW LEFT behaviour
* If some fragment is selected, leave native behaviour
*/
if (blockToMerge.name !== targetBlock.name || !targetBlock.mergeable) {
/** If target Block doesn't contain inputs or empty, remove it */
if (targetBlock.inputs.length === 0 || targetBlock.isEmpty) {
BlockManager.removeBlock(BlockManager.currentBlockIndex - 1);
if (!SelectionUtils.isCollapsed) {
return;
}
Caret.setToBlock(BlockManager.currentBlock);
Toolbar.close();
/**
* If caret is not at the end, leave native behaviour
*/
if (!Caret.isAtEnd) {
return;
}
return;
}
/**
* All the cases below have custom behaviour, so we don't need a native one
*/
event.preventDefault();
this.Editor.Toolbar.close();
if (Caret.navigatePrevious()) {
Toolbar.close();
}
const isLastInputFocused = currentBlock.currentInput === currentBlock.lastInput;
/**
* For example, caret at the end of the Quote first input (quote text) just navigate next input (caption)
*/
if (!isLastInputFocused) {
Caret.navigateNext();
return;
}
/**
* Delete at the end of the last Block should do nothing
*/
if (nextBlock === null) {
return;
}
/**
* If next Block is empty, it should be removed just like a character
*/
if (nextBlock.isEmpty) {
BlockManager.removeBlock(nextBlock);
return;
}
/**
* If current Block is empty, just remove it and set cursor to the next Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
Caret.setToBlock(nextBlock, Caret.positions.START);
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, nextBlock);
/**
* If Blocks could be merged, do it
* Otherwise, just navigate to the next block
*/
if (bothBlocksMergeable) {
this.mergeBlocks(currentBlock, nextBlock);
} else {
Caret.setToBlock(nextBlock, Caret.positions.START);
}
}
/**
* Merge passed Blocks
*
* @param targetBlock - to which Block we want to merge
* @param blockToMerge - what Block we want to merge
*/
private mergeBlocks(targetBlock: Block, blockToMerge: Block): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
Caret.createShadow(targetBlock.pluginsContent);
BlockManager.mergeBlocks(targetBlock, blockToMerge)
BlockManager
.mergeBlocks(targetBlock, blockToMerge)
.then(() => {
/** Restore caret position after merge */
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);

View file

@ -84,7 +84,7 @@ export default class BlockManager extends Module {
*
* @returns {Block|null}
*/
public get nextBlock(): Block {
public get nextBlock(): Block | null {
const isLastBlock = this.currentBlockIndex === (this._blocks.length - 1);
if (isLastBlock) {
@ -121,7 +121,7 @@ export default class BlockManager extends Module {
*
* @returns {Block|null}
*/
public get previousBlock(): Block {
public get previousBlock(): Block | null {
const isFirstBlock = this.currentBlockIndex === 0;
if (isFirstBlock) {
@ -419,29 +419,24 @@ export default class BlockManager extends Module {
* @returns {Promise} - the sequence that can be continued
*/
public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise<void> {
const blockToMergeIndex = this._blocks.indexOf(blockToMerge);
if (blockToMerge.isEmpty) {
return;
}
const blockToMergeData = await blockToMerge.data;
if (!_.isEmpty(blockToMergeData)) {
await targetBlock.mergeWith(blockToMergeData);
}
this.removeBlock(blockToMergeIndex);
this.removeBlock(blockToMerge);
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
}
/**
* Remove block with passed index or remove last
* Remove passed Block
*
* @param {number|null} index - index of Block to remove
* @throws {Error} if Block to remove is not found
* @param block - Block to remove
*/
public removeBlock(index = this.currentBlockIndex): void {
public removeBlock(block: Block): void {
const index = this._blocks.indexOf(block);
/**
* If index is not passed and there is no block selected, show a warning
*/
@ -449,15 +444,13 @@ export default class BlockManager extends Module {
throw new Error('Can\'t find a Block to remove');
}
const blockToRemove = this._blocks[index];
blockToRemove.destroy();
block.destroy();
this._blocks.remove(index);
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, blockToRemove, {
this.blockDidMutated(BlockRemovedMutationType, block, {
index,
});
@ -493,7 +486,7 @@ export default class BlockManager extends Module {
continue;
}
this.removeBlock(index);
this.removeBlock(this.blocks[index]);
firstSelectedBlockIndex = index;
}

View file

@ -322,6 +322,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
private make(): void {
this.nodes.wrapper = $.make('div', this.CSS.toolbar);
/**
* @todo detect test environment and add data-cy="toolbar" to use it in tests instead of class name
*/
/**
* Make Content Zone and Actions Zone

View file

@ -437,6 +437,7 @@ export default class UI extends Module<UINodes> {
break;
case _.keyCodes.BACKSPACE:
case _.keyCodes.DELETE:
this.backspacePressed(event);
break;
@ -684,9 +685,11 @@ export default class UI extends Module<UINodes> {
*/
try {
/**
* Renew Current Block
* Renew Current Block. Use RAF to wait until Selection is set.
*/
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
window.requestAnimationFrame(() => {
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
});
/**
* Highlight Current Node

View file

@ -0,0 +1,15 @@
import type Block from '../block';
/**
* Check if two blocks could be merged.
*
* We can merge two blocks if:
* - they have the same type
* - they have a merge function (.mergeable = true)
*
* @param targetBlock - block to merge to
* @param blockToMerge - block to merge from
*/
export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean {
return targetBlock.mergeable && targetBlock.name === blockToMerge.name;
}

View file

@ -1,78 +0,0 @@
describe('Keydown', function () {
describe('enter', function () {
it('should split block and remove selected fragment if some text fragment selected', function () {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
});
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.selectText('with so')
.wait(0)
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.then((blocks) => {
/**
* Check that there is two blocks after split
*/
expect(blocks.length).to.equal(2);
/**
* Check that selected text fragment has been removed
*/
expect(blocks[0].textContent).to.equal('The block ');
expect(blocks[1].textContent).to.equal('me text');
});
});
it('should set caret to the new block if it was created after Enter key press at very end of the block', function () {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.as('lastBlock');
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@lastBlock').should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
});

View file

@ -0,0 +1,380 @@
import type EditorJS from '../../../../../types/index';
/**
* Creates Editor instance with list of Paragraph blocks of passed texts
*
* @param textBlocks - list of texts for Paragraph blocks
*/
function createEditorWithTextBlocks(textBlocks: string[]): void {
cy.createEditor({
data: {
blocks: textBlocks.map((text) => ({
type: 'paragraph',
data: {
text,
},
})),
},
});
}
describe('Backspace keydown', function () {
it('should just delete chars (native behaviour) when some fragment is selected', function () {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.selectText('The ')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', 'second block');
});
it('should just delete chars (native behaviour) when Caret is not at the start of the Block', function () {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click() // caret will be at the end of the block
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', 'The second bloc'); // last char is removed
});
it('should navigate previous input when Caret is not at the first input', function () {
/**
* Mock of tool with several inputs
*/
class ExampleOfToolWithSeveralInputs {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
const input = document.createElement('div');
const input2 = document.createElement('div');
container.setAttribute('data-cy', 'quote-tool');
input.setAttribute('contenteditable', 'true');
input2.setAttribute('contenteditable', 'true');
container.append(input, input2);
return container;
}
/**
* Saving logic is not necessary for this test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public save(): void {}
}
cy.createEditor({
tools: {
quote: ExampleOfToolWithSeveralInputs,
},
data: {
blocks: [
{
type: 'quote',
data: {},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.last()
.click()
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.first()
.as('firstInput');
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@firstInput').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
it('should remove previous Block if Caret at the start of the Block and previous Block is empty. Also, should close the Toolbox', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: '', // empty block
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}') // move caret to the beginning
.type('{backspace}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block2'); // second block is still here
});
});
it('should remove current Block if it is empty, but previous is not. Also, should close the Toolbox and set Caret to the end of the prev Block', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: '', // empty block
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{backspace}');
/**
* Current Block has been removed
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
});
/**
* Caret is set to the end of the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
expect(range.startOffset).to.be.eq($block[0].textContent.length);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should merge current Block with the previous one if Caret at the start of the Block and both Blocks are mergeable. Also, should close the Toolbox. Caret should be places in a place of glue', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}') // move caret to the beginning
.type('{backspace}');
/**
* Current Block has been removed
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
expect(blocks[0].data.text).to.eq('First blockSecond block'); // text has been merged
});
/**
* Caret is set to the place of merging
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
range.startContainer.normalize(); // glue merged text nodes
expect(range.startOffset).to.be.eq('First block'.length);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge method
*/
class ExampleOfUnmergeableTool {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
container.dataset.cy = 'unmergeable-tool';
container.contentEditable = 'true';
container.innerHTML = 'Unmergeable not empty tool';
return container;
}
/**
* Saving logic is not necessary for this test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public save(): void {}
}
cy.createEditor({
tools: {
code: ExampleOfUnmergeableTool,
},
data: {
blocks: [
{
type: 'code',
data: {},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=unmergeable-tool]')
.as('firstBlock');
/**
* Caret is set to the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@firstBlock').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
describe('at the start of the first Block', function () {
it('should do nothing if Block is not empty', function () {
createEditorWithTextBlocks([ 'The only block. Not empty' ]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{home}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should('have.length', 1)
.should('have.text', 'The only block. Not empty');
});
});
});

View file

@ -0,0 +1,377 @@
import type EditorJS from '../../../../../types/index';
/**
* Creates Editor instance with list of Paragraph blocks of passed texts
*
* @param textBlocks - list of texts for Paragraph blocks
*/
function createEditorWithTextBlocks(textBlocks: string[]): void {
cy.createEditor({
data: {
blocks: textBlocks.map((text) => ({
type: 'paragraph',
data: {
text,
},
})),
},
});
}
describe('Delete keydown', function () {
it('should just delete chars (native behaviour) when some fragment is selected', function () {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.selectText('The ')
.type('{del}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.first()
.should('have.text', 'first block');
});
it('should just delete chars (native behaviour) when Caret is not at the end of the Block', function () {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click() // caret will be at the end of the block
.type('{leftarrow}') // now caret is not at the end
.type('{del}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.first()
.should('have.text', 'The first bloc'); // last char is removed
});
it('should navigate next input when Caret is not at the last input', function () {
/**
* Mock of tool with several inputs
*/
class ExampleOfToolWithSeveralInputs {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
const input = document.createElement('div');
const input2 = document.createElement('div');
container.setAttribute('data-cy', 'quote-tool');
input.setAttribute('contenteditable', 'true');
input2.setAttribute('contenteditable', 'true');
container.append(input, input2);
return container;
}
/**
* Saving logic is not necessary for this test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public save(): void {}
}
cy.createEditor({
tools: {
quote: ExampleOfToolWithSeveralInputs,
},
data: {
blocks: [
{
type: 'quote',
data: {},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.first()
.click()
.type('{del}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.last()
.as('secondInput');
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@secondInput').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
it('should remove next Block if Caret at the end of the Block and next Block is empty. Also, should close the Toolbox', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: '', // empty block
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.type('{del}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // first block is still here
});
});
it('should remove current Block if it is empty, but next is not. Also, should close the Toolbox and set Caret to the start of the next Block', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: '1',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.type('{backspace}') // remove '1' to make block empty
.type('{del}');
/**
* Current Block has been removed
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block2'); // second block is still here
});
/**
* Caret is set to the start of the next Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
expect(range.startOffset).to.be.eq(0);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should merge current Block with the next one if Caret at the end of the Block and both Blocks are mergeable. Also, should close the Toolbox.', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.type('{del}');
/**
* Current Block has been removed
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
expect(blocks[0].data.text).to.eq('First blockSecond block'); // text has been merged
});
/**
* Caret is set to the place of merging
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
range.startContainer.normalize(); // glue merged text nodes
expect(range.startOffset).to.be.eq('First block'.length);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should simply set Caret to the start of the next Block if Caret at the end of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge method
*/
class ExampleOfUnmergeableTool {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
container.dataset.cy = 'unmergeable-tool';
container.contentEditable = 'true';
container.innerHTML = 'Unmergeable not empty tool';
return container;
}
/**
* Saving logic is not necessary for this test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public save(): void {}
}
cy.createEditor({
tools: {
code: ExampleOfUnmergeableTool,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
{
type: 'code',
data: {},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{del}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=unmergeable-tool]')
.as('secondBlock');
/**
* Caret is set to the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@secondBlock').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
describe('at the end of the last Block', function () {
it('should do nothing', function () {
createEditorWithTextBlocks([ 'The only block. Not empty' ]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{del}');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should('have.length', 1)
.should('have.text', 'The only block. Not empty');
});
});
});

View file

@ -0,0 +1,76 @@
describe('Enter keydown', function () {
it('should split block and remove selected fragment if some text fragment selected', function () {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
});
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.selectText('with so')
.wait(0)
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.then((blocks) => {
/**
* Check that there is two blocks after split
*/
expect(blocks.length).to.equal(2);
/**
* Check that selected text fragment has been removed
*/
expect(blocks[0].textContent).to.equal('The block ');
expect(blocks[1].textContent).to.equal('me text');
});
});
it('should set caret to the new block if it was created after Enter key press at very end of the block', function () {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.as('lastBlock');
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('@lastBlock').should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});

View file

@ -0,0 +1,96 @@
import type EditorJS from '../../../../types/index';
describe('Ui module', function () {
describe('documentKeydown', function () {
describe('Backspace', function () {
it('should remove selected blocks', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'The first block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'The second block',
},
},
],
},
}).as('editorInstance');
/**
* Select two blocks by shift+down
*/
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.type('{shift+downArrow}')
.type('{backspace}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
/**
* Actually editor will contain 1 empty block, but save wont return it since it is empty
*/
expect(blocks.length).to.eq(0);
});
});
});
describe('Delete', function () {
it('should remove selected blocks', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'The first block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'The second block',
},
},
],
},
}).as('editorInstance');
/**
* Select two blocks by shift+down
*/
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.type('{shift+downArrow}')
.type('{del}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
/**
* Actually editor will contain 1 empty block, but save wont return it since it is empty
*/
expect(blocks.length).to.eq(0);
});
});
});
});
});