chore(perf): initialisation and rendering performance optimisations (#2430)

* renderer batching

* initialization and rendering performance optimized

* insertMany api method added

* Update index.html

* rm old method

* upd changelog

* upd paragraph

* paste tests fixed

* api blocks tests fixed

* backspace event tests fixed

* async issues in tests fixed

* eslint

* stub block added, tests added

* eslint

* eslint

* add test for insertMany()

* Update package.json
This commit is contained in:
Peter Savchenko 2023-08-08 22:17:09 +03:00 committed by GitHub
parent 0e64665b0f
commit b39996616c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 991 additions and 531 deletions

View file

@ -11,9 +11,22 @@ export default defineConfig({
// We've imported your old cypress plugins here. // We've imported your old cypress plugins here.
// You may want to clean this up later by importing these. // You may want to clean this up later by importing these.
setupNodeEvents(on, config) { setupNodeEvents(on, config) {
return require('./test/cypress/plugins/index.ts')(on, config); /**
* Plugin for cypress that adds better terminal output for easier debugging.
* Prints cy commands, browser console logs, cy.request and cy.intercept data. Great for your pipelines.
* https://github.com/archfz/cypress-terminal-report
*/
require('cypress-terminal-report/src/installLogsPrinter')(on);
require('./test/cypress/plugins/index.ts')(on, config);
}, },
specPattern: 'test/cypress/tests/**/*.cy.{js,jsx,ts,tsx}', specPattern: 'test/cypress/tests/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'test/cypress/support/index.ts', supportFile: 'test/cypress/support/index.ts',
}, },
'retries': {
// Configure retry attempts for `cypress run`
'runMode': 2,
// Configure retry attempts for `cypress open`
'openMode': 0,
},
}); });

View file

@ -3,12 +3,21 @@
### 2.28.0 ### 2.28.0
- `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id. - `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id.
- `New` - The `.convert(blockId, newType)` API method added - `New` - The `blocks.convert(blockId, newType)` API method added. It allows to convert existed Block to a Block of another type.
- `New` - The `blocks.insertMany()` API method added. It allows to insert several Blocks to specified index.
- `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 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` - 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. - `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.
- `Improvement` - Tools shortcuts could be used to convert one Block to another. - `Improvement` - Tools shortcuts could be used to convert one Block to another.
- `Improvement` - Tools shortcuts displayed in the Conversion Toolbar - `Improvement` - Tools shortcuts displayed in the Conversion Toolbar
- `Improvement` - Initialization Loader has been removed.
- `Improvement` - Selection style won't override your custom style for `::selection` outside the editor.
- `Improvement` - Performance optimizations: initialization speed increased, `blocks.render()` API method optimized. Big documents will be displayed faster.
- `Improvement` - "Editor saving" log removed
- `Improvement` - "I'm ready" log removed
- `Improvement` - The stub-block style simplified.
- `Improvement` - If some Block's tool will throw an error during construction, we will show Stub block instead of skipping it during render
- `Improvement` - Call of `blocks.clear()` now will trigger onChange will "block-removed" event for all removed blocks.
### 2.27.2 ### 2.27.2

View file

@ -404,6 +404,8 @@
localStorage.setItem('theme', document.body.classList.contains("dark-mode") ? 'dark' : 'default'); localStorage.setItem('theme', document.body.classList.contains("dark-mode") ? 'dark' : 'default');
}) })
window.editor = editor;
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,6 +1,6 @@
{ {
"name": "@editorjs/editorjs", "name": "@editorjs/editorjs",
"version": "2.28.0-rc.1", "version": "2.28.0-rc.2",
"description": "Editor.js — Native JS, based on API and Open Source", "description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js", "main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs", "module": "dist/editorjs.mjs",
@ -44,7 +44,7 @@
"@editorjs/code": "^2.7.0", "@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0", "@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.7.0", "@editorjs/header": "^2.7.0",
"@editorjs/paragraph": "^2.9.0", "@editorjs/paragraph": "^2.10.0",
"@editorjs/simple-image": "^1.4.1", "@editorjs/simple-image": "^1.4.1",
"@types/node": "^18.15.11", "@types/node": "^18.15.11",
"chai-subset": "^1.6.0", "chai-subset": "^1.6.0",
@ -53,6 +53,7 @@
"core-js": "3.30.0", "core-js": "3.30.0",
"cypress": "^12.9.0", "cypress": "^12.9.0",
"cypress-intellij-reporter": "^0.0.7", "cypress-intellij-reporter": "^0.0.7",
"cypress-terminal-report": "^5.3.2",
"eslint": "^8.37.0", "eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1", "eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2", "eslint-plugin-chai-friendly": "^0.7.2",

View file

@ -252,15 +252,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.holder = this.compose(); this.holder = this.compose();
/** /**
* Start watching block mutations * Bind block events in RIC for optimizing of constructing process time
*/ */
this.watchBlockMutations(); window.requestIdleCallback(() => {
/**
* Start watching block mutations
*/
this.watchBlockMutations();
/** /**
* Mutation observer doesn't track changes in "<input>" and "<textarea>" * Mutation observer doesn't track changes in "<input>" and "<textarea>"
* so we need to track focus events to update current input and clear cache. * so we need to track focus events to update current input and clear cache.
*/ */
this.addInputEvents(); this.addInputEvents();
});
} }
/** /**

View file

@ -220,6 +220,44 @@ export default class Blocks {
} }
} }
/**
* Inserts several blocks at once
*
* @param blocks - blocks to insert
* @param index - index to insert blocks at
*/
public insertMany(blocks: Block[], index: number ): void {
const fragment = new DocumentFragment();
for (const block of blocks) {
fragment.appendChild(block.holder);
}
if (this.length > 0) {
if (index > 0) {
const previousBlockIndex = Math.min(index - 1, this.length - 1);
const previousBlock = this.blocks[previousBlockIndex];
previousBlock.holder.after(fragment);
} else if (index === 0) {
this.workingArea.prepend(fragment);
}
/**
* Insert blocks to the array at the specified index
*/
this.blocks.splice(index, 0, ...blocks);
} else {
this.blocks.push(...blocks);
this.workingArea.appendChild(fragment);
}
/**
* Call Rendered event for each block
*/
blocks.forEach((block) => block.call(BlockToolAPI.RENDERED));
}
/** /**
* Remove block * Remove block
* *

View file

@ -39,7 +39,8 @@ export default class Core {
/** /**
* Ready promise. Resolved if Editor.js is ready to work, rejected otherwise * Ready promise. Resolved if Editor.js is ready to work, rejected otherwise
*/ */
let onReady, onFail; let onReady: (value?: void | PromiseLike<void>) => void;
let onFail: (reason?: unknown) => void;
this.isReady = new Promise((resolve, reject) => { this.isReady = new Promise((resolve, reject) => {
onReady = resolve; onReady = resolve;
@ -50,33 +51,22 @@ export default class Core {
.then(async () => { .then(async () => {
this.configuration = config; this.configuration = config;
await this.validate(); this.validate();
await this.init(); this.init();
await this.start(); await this.start();
await this.render();
_.logLabeled('I\'m ready! (ノ◕ヮ◕)ノ*:・゚✧', 'log', '', 'color: #E24A75'); const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances;
setTimeout(async () => { UI.checkEmptiness();
await this.render(); ModificationsObserver.enable();
if ((this.configuration as EditorConfig).autofocus) { if ((this.configuration as EditorConfig).autofocus) {
const { BlockManager, Caret } = this.moduleInstances; Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
BlockManager.highlightCurrentNode();
}
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START); onReady();
BlockManager.highlightCurrentNode();
}
/**
* Remove loader, show content
*/
this.moduleInstances.UI.removeLoader();
/**
* Resolve this.isReady promise
*/
onReady();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 500);
}) })
.catch((error) => { .catch((error) => {
_.log(`Editor.js is not ready because of ${error}`, 'error'); _.log(`Editor.js is not ready because of ${error}`, 'error');
@ -210,10 +200,8 @@ export default class Core {
/** /**
* Checks for required fields in Editor's config * Checks for required fields in Editor's config
*
* @returns {Promise<void>}
*/ */
public async validate(): Promise<void> { public validate(): void {
const { holderId, holder } = this.config; const { holderId, holder } = this.config;
if (holderId && holder) { if (holderId && holder) {

View file

@ -1,5 +1,5 @@
import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api'; import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
import { BlockToolData, OutputData, ToolConfig } from '../../../../types'; import { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types';
import * as _ from './../../utils'; import * as _ from './../../utils';
import BlockAPI from '../../block/api'; import BlockAPI from '../../block/api';
import Module from '../../__module'; import Module from '../../__module';
@ -32,6 +32,7 @@ export default class BlocksAPI extends Module {
stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status), stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status),
insertNewBlock: (): void => this.insertNewBlock(), insertNewBlock: (): void => this.insertNewBlock(),
insert: this.insert, insert: this.insert,
insertMany: this.insertMany,
update: this.update, update: this.update,
composeBlockData: this.composeBlockData, composeBlockData: this.composeBlockData,
convert: this.convert, convert: this.convert,
@ -181,8 +182,12 @@ export default class BlocksAPI extends Module {
* *
* @param {OutputData} data Saved Editor data * @param {OutputData} data Saved Editor data
*/ */
public render(data: OutputData): Promise<void> { public async render(data: OutputData): Promise<void> {
this.Editor.BlockManager.clear(); if (data === undefined || data.blocks === undefined) {
throw new Error('Incorrect data passed to the render() method');
}
await this.Editor.BlockManager.clear();
return this.Editor.Renderer.render(data.blocks); return this.Editor.Renderer.render(data.blocks);
} }
@ -351,4 +356,51 @@ export default class BlocksAPI extends Module {
throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`); throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`);
} }
}; };
/**
* Inserts several Blocks to a specified index
*
* @param blocks - blocks data to insert
* @param index - index to insert the blocks at
*/
private insertMany = (
blocks: OutputBlockData[],
index: number = this.Editor.BlockManager.blocks.length - 1
): BlockAPIInterface[] => {
this.validateIndex(index);
const blocksToInsert = blocks.map(({ id, type, data }) => {
return this.Editor.BlockManager.composeBlock({
id,
tool: type || (this.config.defaultBlock as string),
data,
});
});
this.Editor.BlockManager.insertMany(blocksToInsert, index);
// we cast to any because our BlockAPI has no "new" signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return blocksToInsert.map((block) => new (BlockAPI as any)(block));
};
/**
* Validated block index and throws an error if it's invalid
*
* @param index - index to validate
*/
private validateIndex(index: unknown): void {
if (typeof index !== 'number') {
throw new Error('Index should be a number');
}
if (index < 0) {
throw new Error(`Index should be greater than or equal to 0`);
}
if (index === null) {
throw new Error(`Index should be greater than or equal to 0`);
}
}
} }

View file

@ -454,10 +454,12 @@ export default class BlockEvents extends Module {
BlockManager BlockManager
.mergeBlocks(targetBlock, blockToMerge) .mergeBlocks(targetBlock, blockToMerge)
.then(() => { .then(() => {
/** Restore caret position after merge */ window.requestAnimationFrame(() => {
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement); /** Restore caret position after merge */
targetBlock.pluginsContent.normalize(); Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
Toolbar.close(); targetBlock.pluginsContent.normalize();
Toolbar.close();
});
}); });
} }

View file

@ -20,6 +20,7 @@ import { BlockChangedMutationType } from '../../../types/events/block/BlockChang
import { BlockChanged } from '../events'; import { BlockChanged } from '../events';
import { clean } from '../utils/sanitizer'; import { clean } from '../utils/sanitizer';
import { convertStringToBlockData } from '../utils/blocks'; import { convertStringToBlockData } from '../utils/blocks';
import PromiseQueue from '../utils/promise-queue';
/** /**
* @typedef {BlockManager} BlockManager * @typedef {BlockManager} BlockManager
@ -244,7 +245,9 @@ export default class BlockManager extends Module {
}, this.eventsDispatcher); }, this.eventsDispatcher);
if (!readOnly) { if (!readOnly) {
this.bindBlockEvents(block); window.requestIdleCallback(() => {
this.bindBlockEvents(block);
}, { timeout: 2000 });
} }
return block; return block;
@ -320,6 +323,16 @@ export default class BlockManager extends Module {
return block; return block;
} }
/**
* Inserts several blocks at once
*
* @param blocks - blocks to insert
* @param index - index where to insert
*/
public insertMany(blocks: Block[], index = 0): void {
this._blocks.insertMany(blocks, index);
}
/** /**
* Replace passed Block with the new one with specified Tool and data * Replace passed Block with the new one with specified Tool and data
* *
@ -433,40 +446,48 @@ export default class BlockManager extends Module {
* Remove passed Block * Remove passed Block
* *
* @param block - Block to remove * @param block - Block to remove
* @param addLastBlock - if true, adds new default block at the end. @todo remove this logic and use event-bus instead
*/ */
public removeBlock(block: Block): void { public removeBlock(block: Block, addLastBlock = true): Promise<void> {
const index = this._blocks.indexOf(block); return new Promise((resolve) => {
const index = this._blocks.indexOf(block);
/** /**
* If index is not passed and there is no block selected, show a warning * If index is not passed and there is no block selected, show a warning
*/ */
if (!this.validateIndex(index)) { if (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove'); throw new Error('Can\'t find a Block to remove');
} }
block.destroy(); block.destroy();
this._blocks.remove(index); this._blocks.remove(index);
/** /**
* Force call of didMutated event on Block removal * Force call of didMutated event on Block removal
*/ */
this.blockDidMutated(BlockRemovedMutationType, block, { this.blockDidMutated(BlockRemovedMutationType, block, {
index, index,
});
if (this.currentBlockIndex >= index) {
this.currentBlockIndex--;
}
/**
* If first Block was removed, insert new Initial Block and set focus on it`s first input
*/
if (!this.blocks.length) {
this.currentBlockIndex = -1;
if (addLastBlock) {
this.insert();
}
} else if (index === 0) {
this.currentBlockIndex = 0;
}
resolve();
}); });
if (this.currentBlockIndex >= index) {
this.currentBlockIndex--;
}
/**
* If first Block was removed, insert new Initial Block and set focus on it`s first input
*/
if (!this.blocks.length) {
this.currentBlockIndex = -1;
this.insert();
} else if (index === 0) {
this.currentBlockIndex = 0;
}
} }
/** /**
@ -804,8 +825,17 @@ export default class BlockManager extends Module {
* we don't need to add an empty default block * we don't need to add an empty default block
* 2) in api.blocks.clear we should add empty block * 2) in api.blocks.clear we should add empty block
*/ */
public clear(needToAddDefaultBlock = false): void { public async clear(needToAddDefaultBlock = false): Promise<void> {
this._blocks.removeAll(); const queue = new PromiseQueue();
this.blocks.forEach((block) => {
queue.add(async () => {
await this.removeBlock(block, false);
});
});
await queue.completed;
this.dropPointer(); this.dropPointer();
if (needToAddDefaultBlock) { if (needToAddDefaultBlock) {

View file

@ -503,13 +503,10 @@ export default class Caret extends Module {
sel.expandToTag(shadowCaret as HTMLElement); sel.expandToTag(shadowCaret as HTMLElement);
setTimeout(() => { const newRange = document.createRange();
const newRange = document.createRange();
newRange.selectNode(shadowCaret); newRange.selectNode(shadowCaret);
newRange.extractContents(); newRange.extractContents();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 50);
} }
/** /**
@ -534,7 +531,7 @@ export default class Caret extends Module {
fragment.appendChild(new Text()); fragment.appendChild(new Text());
} }
const lastChild = fragment.lastChild; const lastChild = fragment.lastChild as ChildNode;
range.deleteContents(); range.deleteContents();
range.insertNode(fragment); range.insertNode(fragment);
@ -542,7 +539,11 @@ export default class Caret extends Module {
/** Cross-browser caret insertion */ /** Cross-browser caret insertion */
const newRange = document.createRange(); const newRange = document.createRange();
newRange.setStart(lastChild, lastChild.textContent.length); const nodeToSetCaret = lastChild.nodeType === Node.TEXT_NODE ? lastChild : lastChild.firstChild;
if (nodeToSetCaret !== null && nodeToSetCaret.textContent !== null) {
newRange.setStart(nodeToSetCaret, nodeToSetCaret.textContent.length);
}
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(newRange); selection.addRange(newRange);

View file

@ -479,9 +479,14 @@ export default class Paste extends Module {
private handlePasteEvent = async (event: ClipboardEvent): Promise<void> => { private handlePasteEvent = async (event: ClipboardEvent): Promise<void> => {
const { BlockManager, Toolbar } = this.Editor; const { BlockManager, Toolbar } = this.Editor;
/**
* When someone pasting into a block, its more stable to set current block by event target, instead of relying on current block set before
*/
const currentBlock = BlockManager.setCurrentBlockByChildNode(event.target as HTMLElement);
/** If target is native input or is not Block, use browser behaviour */ /** If target is native input or is not Block, use browser behaviour */
if ( if (
!BlockManager.currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files')) !currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
) { ) {
return; return;
} }
@ -489,7 +494,7 @@ export default class Paste extends Module {
/** /**
* If Tools is in list of errors, skip processing of paste event * If Tools is in list of errors, skip processing of paste event
*/ */
if (BlockManager.currentBlock && this.exceptionList.includes(BlockManager.currentBlock.name)) { if (currentBlock && this.exceptionList.includes(currentBlock.name)) {
return; return;
} }

View file

@ -1,119 +1,107 @@
import Module from '../__module'; import Module from '../__module';
import * as _ from '../utils'; import * as _ from '../utils';
import { OutputBlockData } from '../../../types'; import type { BlockId, BlockToolData, OutputBlockData } from '../../../types';
import BlockTool from '../tools/block'; import type BlockTool from '../tools/block';
import type { StubData } from '../../tools/stub';
import Block from '../block';
/** /**
* Editor.js Renderer Module * Module that responsible for rendering Blocks on editor initialization
*
* @module Renderer
* @author CodeX Team
* @version 2.0.0
*/ */
export default class Renderer extends Module { export default class Renderer extends Module {
/** /**
* @typedef {object} RendererBlocks * Renders passed blocks as one batch
* @property {string} type - tool name
* @property {object} data - tool data
*/
/**
* @example
* *
* blocks: [ * @param blocksData - blocks to render
* {
* id : 'oDe-EVrGWA',
* type : 'paragraph',
* data : {
* text : 'Hello from Codex!'
* }
* },
* {
* id : 'Ld5BJjJCHs',
* type : 'paragraph',
* data : {
* text : 'Leave feedback if you like it!'
* }
* },
* ]
*/ */
public async render(blocksData: OutputBlockData[]): Promise<void> {
return new Promise((resolve) => {
const { Tools, BlockManager } = this.Editor;
/** /**
* Make plugin blocks from array of plugin`s data * Create Blocks instances
* */
* @param {OutputBlockData[]} blocks - blocks to render const blocks = blocksData.map(({ type: tool, data, tunes, id }) => {
*/ if (Tools.available.has(tool) === false) {
public async render(blocks: OutputBlockData[]): Promise<void> { _.logLabeled(`Tool «${tool}» is not found. Check 'tools' property at the Editor.js config.`, 'warn');
const chainData = blocks.map((block) => ({ function: (): Promise<void> => this.insertBlock(block) }));
/** data = this.composeStubDataForTool(tool, data, id);
* Disable onChange callback on render to not to spam those events tool = Tools.stubTool;
*/ }
this.Editor.ModificationsObserver.disable();
const sequence = await _.sequence(chainData as _.ChainData[]); let block: Block;
this.Editor.ModificationsObserver.enable(); try {
block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'error', {
data,
error,
});
this.Editor.UI.checkEmptiness(); /**
* If tool throws an error during render, we should render stub instead of it
*/
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
return sequence; block = BlockManager.composeBlock({
} id,
tool,
data,
tunes,
});
}
/** return block;
* Get plugin instance
* Add plugin instance to BlockManager
* Insert block to working zone
*
* @param {object} item - Block data to insert
* @returns {Promise<void>}
*/
public async insertBlock(item: OutputBlockData): Promise<void> {
const { Tools, BlockManager } = this.Editor;
const { type: tool, data, tunes, id } = item;
if (Tools.available.has(tool)) {
try {
BlockManager.insert({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'warn', {
data,
error,
});
throw Error(error);
}
} else {
/** If Tool is unavailable, create stub Block for it */
const stubData = {
savedData: {
id,
type: tool,
data,
},
title: tool,
};
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
const toolboxTitle = toolboxSettings[0]?.title;
stubData.title = toolboxTitle || stubData.title;
}
const stub = BlockManager.insert({
id,
tool: Tools.stubTool,
data: stubData,
}); });
stub.stretched = true; /**
* Insert batch of Blocks
*/
BlockManager.insertMany(blocks);
_.log(`Tool «${tool}» is not found. Check 'tools' property at your initial Editor.js config.`, 'warn'); /**
* Wait till browser will render inserted Blocks and resolve a promise
*/
window.requestIdleCallback(() => {
resolve();
}, { timeout: 2000 });
});
}
/**
* Create data for the Stub Tool that will be used instead of unavailable tool
*
* @param tool - unavailable tool name to stub
* @param data - data of unavailable block
* @param [id] - id of unavailable block
*/
private composeStubDataForTool(tool: string, data: BlockToolData, id?: BlockId): StubData {
const { Tools } = this.Editor;
let title = tool;
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
if (toolboxSettings !== undefined && toolboxSettings[0].title !== undefined) {
title = toolboxSettings[0].title;
}
} }
return {
savedData: {
id,
type: tool,
data,
},
title,
};
} }
} }

View file

@ -70,26 +70,11 @@ export default class Saver extends Module {
* @returns {OutputData} * @returns {OutputData}
*/ */
private makeOutput(allExtractedData): OutputData { private makeOutput(allExtractedData): OutputData {
let totalTime = 0;
const blocks = []; const blocks = [];
_.log('[Editor.js saving]:', 'groupCollapsed'); allExtractedData.forEach(({ id, tool, data, tunes, isValid }) => {
if (!isValid) {
allExtractedData.forEach(({ id, tool, data, tunes, time, isValid }) => {
totalTime += time;
/**
* Capitalize Tool name
*/
_.log(`${tool.charAt(0).toUpperCase() + tool.slice(1)}`, 'group');
if (isValid) {
/** Group process info */
_.log(data);
_.log(undefined, 'groupEnd');
} else {
_.log(`Block «${tool}» skipped because saved data is invalid`); _.log(`Block «${tool}» skipped because saved data is invalid`);
_.log(undefined, 'groupEnd');
return; return;
} }
@ -113,9 +98,6 @@ export default class Saver extends Module {
blocks.push(output); blocks.push(output);
}); });
_.log('Total', 'log', totalTime);
_.log(undefined, 'groupEnd');
return { return {
time: +new Date(), time: +new Date(),
blocks, blocks,

View file

@ -103,8 +103,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
/** /**
* Toolbox class instance * Toolbox class instance
* It will be created in requestIdleCallback so it can be null in some period of time
*/ */
private toolboxInstance: Toolbox; private toolboxInstance: Toolbox | null = null;
/** /**
* @class * @class
@ -155,18 +156,27 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Public interface for accessing the Toolbox * Public interface for accessing the Toolbox
*/ */
public get toolbox(): { public get toolbox(): {
opened: boolean; opened: boolean | undefined; // undefined is for the case when Toolbox is not initialized yet
close: () => void; close: () => void;
open: () => void; open: () => void;
toggle: () => void; toggle: () => void;
hasFocus: () => boolean; hasFocus: () => boolean | undefined;
} { } {
return { return {
opened: this.toolboxInstance.opened, opened: this.toolboxInstance?.opened,
close: (): void => { close: () => {
this.toolboxInstance.close(); this.toolboxInstance?.close();
}, },
open: (): void => { open: () => {
/**
* If Toolbox is not initialized yet, do nothing
*/
if (this.toolboxInstance === null) {
_.log('toolbox.open() called before initialization is finished', 'warn');
return;
}
/** /**
* Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block. * Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block.
*/ */
@ -174,8 +184,19 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.toolboxInstance.open(); this.toolboxInstance.open();
}, },
toggle: (): void => this.toolboxInstance.toggle(), toggle: () => {
hasFocus: (): boolean => this.toolboxInstance.hasFocus(), /**
* If Toolbox is not initialized yet, do nothing
*/
if (this.toolboxInstance === null) {
_.log('toolbox.toggle() called before initialization is finished', 'warn');
return;
}
this.toolboxInstance.toggle();
},
hasFocus: () => this.toolboxInstance?.hasFocus(),
}; };
} }
@ -210,8 +231,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/ */
public toggleReadOnly(readOnlyEnabled: boolean): void { public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) { if (!readOnlyEnabled) {
this.drawUI(); window.requestIdleCallback(() => {
this.enableModuleBindings(); this.drawUI();
this.enableModuleBindings();
}, { timeout: 2000 });
} else { } else {
this.destroy(); this.destroy();
this.Editor.BlockSettings.destroy(); this.Editor.BlockSettings.destroy();
@ -225,6 +248,15 @@ export default class Toolbar extends Module<ToolbarNodes> {
* @param block - block to move Toolbar near it * @param block - block to move Toolbar near it
*/ */
public moveAndOpen(block: Block = this.Editor.BlockManager.currentBlock): void { public moveAndOpen(block: Block = this.Editor.BlockManager.currentBlock): void {
/**
* Some UI elements creates inside requestIdleCallback, so the can be not ready yet
*/
if (this.toolboxInstance === null) {
_.log('Can\'t open Toolbar since Editor initialization is not finished yet', 'warn');
return;
}
/** /**
* Close Toolbox when we move toolbar * Close Toolbox when we move toolbar
*/ */
@ -294,7 +326,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/** Close components */ /** Close components */
this.blockActions.hide(); this.blockActions.hide();
this.toolboxInstance.close(); this.toolboxInstance?.close();
this.Editor.BlockSettings.close(); this.Editor.BlockSettings.close();
} }
@ -454,7 +486,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/ */
this.Editor.BlockManager.currentBlock = this.hoveredBlock; this.Editor.BlockManager.currentBlock = this.hoveredBlock;
this.toolboxInstance.toggle(); this.toolboxInstance?.toggle();
} }
/** /**
@ -476,7 +508,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.settingsTogglerClicked(); this.settingsTogglerClicked();
if (this.toolboxInstance.opened) { if (this.toolboxInstance?.opened) {
this.toolboxInstance.close(); this.toolboxInstance.close();
} }
@ -496,7 +528,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/** /**
* Do not move toolbar if Block Settings or Toolbox opened * Do not move toolbar if Block Settings or Toolbox opened
*/ */
if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) { if (this.Editor.BlockSettings.opened || this.toolboxInstance?.opened) {
return; return;
} }

View file

@ -122,7 +122,9 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/ */
public toggleReadOnly(readOnlyEnabled: boolean): void { public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) { if (!readOnlyEnabled) {
this.make(); window.requestIdleCallback(() => {
this.make();
}, { timeout: 2000 });
} else { } else {
this.destroy(); this.destroy();
this.Editor.ConversionToolbar.destroy(); this.Editor.ConversionToolbar.destroy();
@ -359,8 +361,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/** /**
* Recalculate initial width with all buttons * Recalculate initial width with all buttons
* We use RIC to prevent forced layout during editor initialization to make it faster
*/ */
this.recalculateWidth(); window.requestAnimationFrame(() => {
this.recalculateWidth();
});
/** /**
* Allow to leaf buttons by arrows / tab * Allow to leaf buttons by arrows / tab

View file

@ -22,7 +22,6 @@ interface UINodes {
holder: HTMLElement; holder: HTMLElement;
wrapper: HTMLElement; wrapper: HTMLElement;
redactor: HTMLElement; redactor: HTMLElement;
loader: HTMLElement;
} }
/** /**
@ -49,14 +48,13 @@ export default class UI extends Module<UINodes> {
*/ */
public get CSS(): { public get CSS(): {
editorWrapper: string; editorWrapperNarrow: string; editorZone: string; editorZoneHidden: string; editorWrapper: string; editorWrapperNarrow: string; editorZone: string; editorZoneHidden: string;
editorLoader: string; editorEmpty: string; editorRtlFix: string; editorEmpty: string; editorRtlFix: string;
} { } {
return { return {
editorWrapper: 'codex-editor', editorWrapper: 'codex-editor',
editorWrapperNarrow: 'codex-editor--narrow', editorWrapperNarrow: 'codex-editor--narrow',
editorZone: 'codex-editor__redactor', editorZone: 'codex-editor__redactor',
editorZoneHidden: 'codex-editor__redactor--hidden', editorZoneHidden: 'codex-editor__redactor--hidden',
editorLoader: 'codex-editor__loader',
editorEmpty: 'codex-editor--empty', editorEmpty: 'codex-editor--empty',
editorRtlFix: 'codex-editor--rtl', editorRtlFix: 'codex-editor--rtl',
}; };
@ -115,23 +113,6 @@ export default class UI extends Module<UINodes> {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers // eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 200); }, 200);
/**
* Adds loader to editor while content is not ready
*/
public addLoader(): void {
this.nodes.loader = $.make('div', this.CSS.editorLoader);
this.nodes.wrapper.prepend(this.nodes.loader);
this.nodes.redactor.classList.add(this.CSS.editorZoneHidden);
}
/**
* Removes loader when content has loaded
*/
public removeLoader(): void {
this.nodes.loader.remove();
this.nodes.redactor.classList.remove(this.CSS.editorZoneHidden);
}
/** /**
* Making main interface * Making main interface
*/ */
@ -146,11 +127,6 @@ export default class UI extends Module<UINodes> {
*/ */
this.make(); this.make();
/**
* Loader for rendering process
*/
this.addLoader();
/** /**
* Load and append CSS * Load and append CSS
*/ */
@ -277,6 +253,8 @@ export default class UI extends Module<UINodes> {
/** /**
* If Editor has injected into the narrow container, enable Narrow Mode * If Editor has injected into the narrow container, enable Narrow Mode
*
* @todo Forced layout. Get rid of this feature
*/ */
if (this.nodes.holder.offsetWidth < this.contentRect.width) { if (this.nodes.holder.offsetWidth < this.contentRect.width) {
this.nodes.wrapper.classList.add(this.CSS.editorWrapperNarrow); this.nodes.wrapper.classList.add(this.CSS.editorWrapperNarrow);
@ -684,12 +662,7 @@ export default class UI extends Module<UINodes> {
* Select clicked Block as Current * Select clicked Block as Current
*/ */
try { try {
/** this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
* Renew Current Block. Use RAF to wait until Selection is set.
*/
window.requestAnimationFrame(() => {
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
});
/** /**
* Highlight Current Node * Highlight Current Node

View file

@ -136,3 +136,27 @@ if (!Element.prototype.scrollIntoViewIfNeeded) {
} }
}; };
} }
/**
* RequestIdleCallback polyfill (shims)
*
* @see https://developer.chrome.com/blog/using-requestidlecallback/
* @param cb - callback to be executed when the browser is idle
*/
window.requestIdleCallback = window.requestIdleCallback || function (cb) {
const start = Date.now();
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};
window.cancelIdleCallback = window.cancelIdleCallback || function (id) {
clearTimeout(id);
};

View file

@ -225,7 +225,7 @@ export default class SelectionUtils {
* *
* @param selection - Selection object to get Range from * @param selection - Selection object to get Range from
*/ */
public static getRangeFromSelection(selection: Selection): Range { public static getRangeFromSelection(selection: Selection): Range | null {
return selection && selection.rangeCount ? selection.getRangeAt(0) : null; return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
} }

View file

@ -311,6 +311,7 @@ export function isPrintableKey(keyCode: number): boolean {
* @param {Function} success - success callback * @param {Function} success - success callback
* @param {Function} fallback - callback that fires in case of errors * @param {Function} fallback - callback that fires in case of errors
* @returns {Promise} * @returns {Promise}
* @deprecated use PromiseQueue.ts instead
*/ */
export async function sequence( export async function sequence(
chains: ChainData[], chains: ChainData[],

View file

@ -94,6 +94,12 @@ export default class EventsDispatcher<EventMap> {
* @param callback - event handler * @param callback - event handler
*/ */
public off<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void { public off<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
if (this.subscribers[eventName] === undefined) {
console.warn(`EventDispatcher .off(): there is no subscribers for event "${eventName.toString()}". Probably, .off() called before .on()`);
return;
}
for (let i = 0; i < this.subscribers[eventName].length; i++) { for (let i = 0; i < this.subscribers[eventName].length; i++) {
if (this.subscribers[eventName][i] === callback) { if (this.subscribers[eventName][i] === callback) {
delete this.subscribers[eventName][i]; delete this.subscribers[eventName][i];
@ -107,6 +113,6 @@ export default class EventsDispatcher<EventMap> {
* clears subscribers list * clears subscribers list
*/ */
public destroy(): void { public destroy(): void {
this.subscribers = null; this.subscribers = {} as Subscriptions<EventMap>;
} }
} }

View file

@ -0,0 +1,28 @@
/**
* Class allows to make a queue of async jobs and wait until they all will be finished one by one
*
* @example const queue = new PromiseQueue();
* queue.add(async () => { ... });
* queue.add(async () => { ... });
* await queue.completed;
*/
export default class PromiseQueue {
/**
* Queue of promises to be executed
*/
public completed = Promise.resolve();
/**
* Add new promise to queue
*
* @param operation - promise should be added to queue
*/
public add(operation: (value: void) => void | PromiseLike<void>): Promise<void> {
return new Promise((resolve, reject) => {
this.completed = this.completed
.then(operation)
.then(resolve)
.catch(reject);
});
}
}

View file

@ -1,26 +1,25 @@
.ce-stub { .ce-stub {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; padding: 12px 18px;
width: 100%; margin: 10px 0;
padding: 3.5em 0; border-radius: 10px;
margin: 17px 0; background: var(--bg-light);
border-radius: 3px; border: 1px solid var(--color-line-gray);
background: #fcf7f7; color: var(--grayText);
color: #b46262; font-size: 14px;
svg {
width: var(--icon-size);
height: var(--icon-size);
}
&__info { &__info {
margin-left: 20px; margin-left: 14px;
} }
&__title { &__title {
margin-bottom: 3px; font-weight: 500;
font-weight: 600;
font-size: 18px;
text-transform: capitalize; text-transform: capitalize;
} }
&__subtitle {
font-size: 16px;
}
} }

View file

@ -11,10 +11,6 @@
} }
&__redactor { &__redactor {
&--hidden {
display: none;
}
/** /**
* Workaround firefox bug: empty content editable elements has collapsed height * Workaround firefox bug: empty content editable elements has collapsed height
* https://bugzilla.mozilla.org/show_bug.cgi?id=1098151#c18 * https://bugzilla.mozilla.org/show_bug.cgi?id=1098151#c18
@ -46,28 +42,6 @@
} }
} }
&__loader {
position: relative;
height: 30vh;
&::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 30px;
height: 30px;
margin-top: -15px;
margin-left: -15px;
border-radius: 50%;
border: 2px solid var(--color-gray-border);
border-top-color: transparent;
box-sizing: border-box;
animation: editor-loader-spin 800ms infinite linear;
will-change: transform;
}
}
&-copyable { &-copyable {
position: absolute; position: absolute;
height: 1px; height: 1px;
@ -107,29 +81,21 @@
path { path {
stroke: currentColor; stroke: currentColor;
} }
/**
* Set color for native selection
*/
::selection{
background-color: var(--inlineSelectionColor);
}
} }
/**
* Set color for native selection
*/
::selection{
background-color: var(--inlineSelectionColor);
}
.codex-editor--toolbox-opened [contentEditable=true][data-placeholder]:focus::before { .codex-editor--toolbox-opened [contentEditable=true][data-placeholder]:focus::before {
opacity: 0 !important; opacity: 0 !important;
} }
@keyframes editor-loader-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.ce-scroll-locked { .ce-scroll-locked {
overflow: hidden; overflow: hidden;
} }

View file

@ -1,5 +1,6 @@
import $ from '../../components/dom'; import $ from '../../components/dom';
import { API, BlockTool, BlockToolConstructorOptions, BlockToolData } from '../../../types'; import { API, BlockTool, BlockToolConstructorOptions, BlockToolData } from '../../../types';
import { IconWarning } from '@codexteam/icons';
export interface StubData extends BlockToolData { export interface StubData extends BlockToolData {
title: string; title: string;
@ -92,7 +93,7 @@ export default class Stub implements BlockTool {
*/ */
private make(): HTMLElement { private make(): HTMLElement {
const wrapper = $.make('div', this.CSS.wrapper); const wrapper = $.make('div', this.CSS.wrapper);
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#D76B6B" fill-rule="nonzero" d="M26 52C11.64 52 0 40.36 0 26S11.64 0 26 0s26 11.64 26 26-11.64 26-26 26zm0-3.25c12.564 0 22.75-10.186 22.75-22.75S38.564 3.25 26 3.25 3.25 13.436 3.25 26 13.436 48.75 26 48.75zM15.708 33.042a2.167 2.167 0 1 1 0-4.334 2.167 2.167 0 0 1 0 4.334zm23.834 0a2.167 2.167 0 1 1 0-4.334 2.167 2.167 0 0 1 0 4.334zm-15.875 5.452a1.083 1.083 0 1 1-1.834-1.155c1.331-2.114 3.49-3.179 6.334-3.179 2.844 0 5.002 1.065 6.333 3.18a1.083 1.083 0 1 1-1.833 1.154c-.913-1.45-2.366-2.167-4.5-2.167s-3.587.717-4.5 2.167z"/></svg>`; const icon = IconWarning;
const infoContainer = $.make('div', this.CSS.info); const infoContainer = $.make('div', this.CSS.info);
const title = $.make('div', this.CSS.title, { const title = $.make('div', this.CSS.title, {
textContent: this.title, textContent: this.title,

View file

@ -11,7 +11,9 @@
"plugin:chai-friendly/recommended" "plugin:chai-friendly/recommended"
], ],
"rules": { "rules": {
"cypress/require-data-selectors": 2 "cypress/require-data-selectors": 2,
"cypress/no-unnecessary-waiting": 0,
"@typescript-eslint/no-magic-numbers": 0
}, },
"globals": { "globals": {
"EditorJS": true "EditorJS": true

View file

@ -61,7 +61,7 @@ Cypress.Commands.add('paste', {
subject[0].dispatchEvent(pasteEvent); subject[0].dispatchEvent(pasteEvent);
return subject; cy.wait(200); // wait a little since some tools (paragraph) could have async hydration
}); });
/** /**
@ -79,7 +79,6 @@ Cypress.Commands.add('copy', { prevSubject: true }, (subject) => {
}), { }), {
clipboardData: { clipboardData: {
setData: (type: string, data: any): void => { setData: (type: string, data: any): void => {
console.log(type, data);
clipboardData[type] = data; clipboardData[type] = data;
}, },
}, },
@ -105,7 +104,6 @@ Cypress.Commands.add('cut', { prevSubject: true }, (subject) => {
}), { }), {
clipboardData: { clipboardData: {
setData: (type: string, data: any): void => { setData: (type: string, data: any): void => {
console.log(type, data);
clipboardData[type] = data; clipboardData[type] = data;
}, },
}, },
@ -122,9 +120,10 @@ Cypress.Commands.add('cut', { prevSubject: true }, (subject) => {
* @param data data to render * @param data data to render
*/ */
Cypress.Commands.add('render', { prevSubject: true }, (subject: EditorJS, data: OutputData) => { Cypress.Commands.add('render', { prevSubject: true }, (subject: EditorJS, data: OutputData) => {
subject.render(data); return cy.wrap(subject.render(data))
.then(() => {
return cy.wrap(subject); return cy.wrap(subject);
});
}); });
@ -154,5 +153,5 @@ Cypress.Commands.add('selectText', {
document.getSelection().removeAllRanges(); document.getSelection().removeAllRanges();
document.getSelection().addRange(range); document.getSelection().addRange(range);
return subject; return cy.wrap(subject);
}); });

View file

@ -7,6 +7,9 @@
*/ */
import '@cypress/code-coverage/support'; import '@cypress/code-coverage/support';
import installLogsCollector from 'cypress-terminal-report/src/installLogsCollector';
installLogsCollector();
/** /**
* File with the helpful commands * File with the helpful commands

View file

@ -63,9 +63,7 @@ describe('api.blocks', () => {
it('should update block in DOM', () => { it('should update block in DOM', () => {
cy.createEditor({ cy.createEditor({
data: editorDataMock, data: editorDataMock,
}).as('editorInstance'); }).then((editor) => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
const idToUpdate = firstBlock.id; const idToUpdate = firstBlock.id;
const newBlockData = { const newBlockData = {
text: 'Updated text', text: 'Updated text',
@ -75,10 +73,7 @@ describe('api.blocks', () => {
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.invoke('text') .should('have.text', newBlockData.text);
.then(blockText => {
expect(blockText).to.be.eq(newBlockData.text);
});
}); });
}); });
@ -88,9 +83,7 @@ describe('api.blocks', () => {
it('should update block in saved data', () => { it('should update block in saved data', () => {
cy.createEditor({ cy.createEditor({
data: editorDataMock, data: editorDataMock,
}).as('editorInstance'); }).then((editor) => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
const idToUpdate = firstBlock.id; const idToUpdate = firstBlock.id;
const newBlockData = { const newBlockData = {
text: 'Updated text', text: 'Updated text',
@ -98,10 +91,14 @@ describe('api.blocks', () => {
editor.blocks.update(idToUpdate, newBlockData); editor.blocks.update(idToUpdate, newBlockData);
const output = await editor.save(); // wait a little since some tools (paragraph) could have async hydration
const text = output.blocks[0].data.text; cy.wait(100).then(() => {
editor.save().then((output) => {
const text = output.blocks[0].data.text;
expect(text).to.be.eq(newBlockData.text); expect(text).to.be.eq(newBlockData.text);
});
});
}); });
}); });
@ -111,9 +108,7 @@ describe('api.blocks', () => {
it('shouldn\'t update any block if not-existed id passed', () => { it('shouldn\'t update any block if not-existed id passed', () => {
cy.createEditor({ cy.createEditor({
data: editorDataMock, data: editorDataMock,
}).as('editorInstance'); }).then((editor) => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
const idToUpdate = 'wrong-id-123'; const idToUpdate = 'wrong-id-123';
const newBlockData = { const newBlockData = {
text: 'Updated text', text: 'Updated text',
@ -138,9 +133,7 @@ describe('api.blocks', () => {
it('should preserve block id if it is passed', function () { it('should preserve block id if it is passed', function () {
cy.createEditor({ cy.createEditor({
data: editorDataMock, data: editorDataMock,
}).as('editorInstance'); }).then((editor) => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
const type = 'paragraph'; const type = 'paragraph';
const data = { text: 'codex' }; const data = { text: 'codex' };
const config = undefined; const config = undefined;
@ -157,6 +150,53 @@ describe('api.blocks', () => {
}); });
}); });
/**
* api.blocks.insertMany(blocks, index)
*/
describe('.insertMany()', function () {
it('should insert several blocks to passed index', function () {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: { text: 'first block' },
}
],
},
}).then((editor) => {
const index = 0;
cy.wrap(editor.blocks.insertMany([
{
type: 'paragraph',
data: { text: 'inserting block #1' },
},
{
type: 'paragraph',
data: { text: 'inserting block #2' },
},
], index)); // paste to the 0 index
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.each(($el, i) => {
switch (i) {
case 0:
cy.wrap($el).should('have.text', 'inserting block #1');
break;
case 1:
cy.wrap($el).should('have.text', 'inserting block #2');
break;
case 2:
cy.wrap($el).should('have.text', 'first block');
break;
}
});
});
});
});
describe('.convert()', function () { describe('.convert()', function () {
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () { it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () {
/** /**
@ -202,42 +242,31 @@ describe('api.blocks', () => {
existingBlock, existingBlock,
], ],
}, },
}).as('editorInstance'); }).then((editor) => {
const { convert } = editor.blocks;
/** convert(existingBlock.id, 'convertableTool');
* Call the 'convert' api method
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { convert } = editor.blocks;
convert(existingBlock.id, 'convertableTool'); // wait for block to be converted
}); cy.wait(100).then(() => {
/**
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers -- wait for block to be converted * Check that block was converted
cy.wait(100); */
editor.save().then(( { blocks }) => {
/** expect(blocks.length).to.eq(1);
* Check that block was converted expect(blocks[0].type).to.eq('convertableTool');
*/ expect(blocks[0].data.text).to.eq(existingBlock.data.text);
cy.get<EditorJS>('@editorInstance') });
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
}); });
});
}); });
it('should throw an error if nonexisting Block id passed', function () { it('should throw an error if nonexisting Block id passed', function () {
cy.createEditor({}).as('editorInstance'); cy.createEditor({})
.then((editor) => {
/** /**
* Call the 'convert' api method with nonexisting Block id * Call the 'convert' api method with nonexisting Block id
*/ */
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const fakeId = 'WRNG_ID'; const fakeId = 'WRNG_ID';
const { convert } = editor.blocks; const { convert } = editor.blocks;
@ -262,20 +291,17 @@ describe('api.blocks', () => {
existingBlock, existingBlock,
], ],
}, },
}).as('editorInstance'); }).then((editor) => {
/**
* Call the 'convert' api method with nonexisting tool name
*/
const nonexistingToolName = 'WRNG_TOOL_NAME';
const { convert } = editor.blocks;
/** const exec = (): void => convert(existingBlock.id, nonexistingToolName);
* Call the 'convert' api method with nonexisting tool name
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const nonexistingToolName = 'WRNG_TOOL_NAME';
const { convert } = editor.blocks;
const exec = (): void => convert(existingBlock.id, nonexistingToolName); expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
});
expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
});
}); });
it('should throw an error if some tool does not provide "conversionConfig"', function () { it('should throw an error if some tool does not provide "conversionConfig"', function () {
@ -304,19 +330,16 @@ describe('api.blocks', () => {
existingBlock, existingBlock,
], ],
}, },
}).as('editorInstance'); }).then((editor) => {
/**
* Call the 'convert' api method with tool that does not provide "conversionConfig"
*/
const { convert } = editor.blocks;
/** const exec = (): void => convert(existingBlock.id, 'nonConvertableTool');
* Call the 'convert' api method with tool that does not provide "conversionConfig"
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { convert } = editor.blocks;
const exec = (): void => convert(existingBlock.id, 'nonConvertableTool'); expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
});
expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
});
}); });
}); });
}); });

View file

@ -1,23 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Header from '@editorjs/header'; import Header from '@editorjs/header';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type EditorJS from '../../../types/index';
describe('Block ids', () => { describe('Block ids', () => {
beforeEach(function () { it('Should generate unique block ids for new blocks', () => {
cy.createEditor({ cy.createEditor({
tools: { tools: {
header: Header, header: Header,
}, },
}).as('editorInstance'); }).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance) {
this.editorInstance.destroy();
}
});
it('Should generate unique block ids for new blocks', () => {
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -42,8 +35,8 @@ describe('Block ids', () => {
.click() .click()
.type('Header'); .type('Header');
cy.get('@editorInstance') cy.get<EditorJS>('@editorInstance')
.then(async (editor: any) => { .then(async (editor) => {
const data = await editor.save(); const data = await editor.save();
data.blocks.forEach(block => { data.blocks.forEach(block => {
@ -53,6 +46,9 @@ describe('Block ids', () => {
}); });
it('should preserve passed ids', () => { it('should preserve passed ids', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [ const blocks = [
{ {
id: nanoid(), id: nanoid(),
@ -70,19 +66,13 @@ describe('Block ids', () => {
}, },
]; ];
cy.get('@editorInstance') cy.get<EditorJS>('@editorInstance')
.render({ .render({
blocks, blocks,
}); });
cy.get('[data-cy=editorjs]') cy.get<EditorJS>('@editorInstance')
.get('div.ce-block') .then(async (editor) => {
.first()
.click()
.type('{movetoend} Some more text');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save(); const data = await editor.save();
data.blocks.forEach((block, index) => { data.blocks.forEach((block, index) => {
@ -92,6 +82,9 @@ describe('Block ids', () => {
}); });
it('should preserve passed ids if blocks were added', () => { it('should preserve passed ids if blocks were added', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [ const blocks = [
{ {
id: nanoid(), id: nanoid(),
@ -109,7 +102,7 @@ describe('Block ids', () => {
}, },
]; ];
cy.get('@editorInstance') cy.get<EditorJS>('@editorInstance')
.render({ .render({
blocks, blocks,
}); });
@ -122,16 +115,20 @@ describe('Block ids', () => {
.next() .next()
.type('Middle block'); .type('Middle block');
cy.get('@editorInstance') cy.get<EditorJS>('@editorInstance')
.then(async (editor: any) => { .then(async (editor) => {
const data = await editor.save(); cy.wrap(await editor.save())
.then((data) => {
expect(data.blocks[0].id).to.eq(blocks[0].id); expect(data.blocks[0].id).to.eq(blocks[0].id);
expect(data.blocks[2].id).to.eq(blocks[1].id); expect(data.blocks[2].id).to.eq(blocks[1].id);
});
}); });
}); });
it('should be stored at the Block wrapper\'s data-id attribute', () => { it('should be stored at the Block wrapper\'s data-id attribute', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [ const blocks = [
{ {
id: nanoid(), id: nanoid(),
@ -149,7 +146,7 @@ describe('Block ids', () => {
}, },
]; ];
cy.get('@editorInstance') cy.get<EditorJS>('@editorInstance')
.render({ .render({
blocks, blocks,
}); });

View file

@ -1,51 +1,44 @@
import Header from '@editorjs/header'; import Header from '@editorjs/header';
import Image from '@editorjs/simple-image'; import Image from '@editorjs/simple-image';
import * as _ from '../../../src/components/utils'; import * as _ from '../../../src/components/utils';
import EditorJS, { BlockTool, BlockToolData } from '../../../types'; import { BlockTool, BlockToolData } from '../../../types';
import $ from '../../../src/components/dom'; import $ from '../../../src/components/dom';
describe('Copy pasting from Editor', function () { describe('Copy pasting from Editor', function () {
beforeEach(function () {
cy.createEditor({
tools: {
header: Header,
image: Image,
},
}).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance && this.editorInstance.destroy) {
this.editorInstance.destroy();
}
});
context('pasting', function () { context('pasting', function () {
it('should paste plain text', function () { it('should paste plain text', function () {
// eslint-disable-next-line cypress/no-unnecessary-waiting cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.as('block')
.click() .click()
.paste({ .paste({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Some plain text', 'text/plain': 'Some plain text',
}) });
.wait(0)
.should('contain', 'Some plain text'); cy.get('@block').should('contain', 'Some plain text');
}); });
it('should paste inline html data', function () { it('should paste inline html data', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.as('block')
.click() .click()
.paste({ .paste({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<p><b>Some text</b></p>', 'text/html': '<p><b>Some text</b></p>',
}) });
.should('contain.html', '<b>Some text</b>');
cy.get('@block').should('contain.html', '<b>Some text</b>');
}); });
it('should paste several blocks if plain text contains new lines', function () { it('should paste several blocks if plain text contains new lines', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -63,6 +56,8 @@ describe('Copy pasting from Editor', function () {
}); });
it('should paste several blocks if html contains several paragraphs', function () { it('should paste several blocks if html contains several paragraphs', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -80,6 +75,8 @@ describe('Copy pasting from Editor', function () {
}); });
it('should paste using custom data type', function () { it('should paste using custom data type', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -110,6 +107,12 @@ describe('Copy pasting from Editor', function () {
}); });
it('should parse block tags', function () { it('should parse block tags', function () {
cy.createEditor({
tools: {
header: Header,
},
});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -128,6 +131,12 @@ describe('Copy pasting from Editor', function () {
}); });
it('should parse pattern', function () { it('should parse pattern', function () {
cy.createEditor({
tools: {
image: Image,
},
});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -143,12 +152,6 @@ describe('Copy pasting from Editor', function () {
}); });
it('should not prevent default behaviour if block\'s paste config equals false', function () { it('should not prevent default behaviour if block\'s paste config equals false', function () {
/**
* Destroy default Editor to render custom one with different tools
*/
cy.get('@editorInstance')
.then((editorInstance: unknown) => (editorInstance as EditorJS).destroy());
const onPasteStub = cy.stub().as('onPaste'); const onPasteStub = cy.stub().as('onPaste');
/** /**
@ -182,7 +185,8 @@ describe('Copy pasting from Editor', function () {
tools: { tools: {
blockToolWithPasteHandler: BlockToolWithPasteHandler, blockToolWithPasteHandler: BlockToolWithPasteHandler,
}, },
}).as('editorInstanceWithBlockToolWithPasteHandler'); })
.as('editorInstanceWithBlockToolWithPasteHandler');
cy.get('@editorInstanceWithBlockToolWithPasteHandler') cy.get('@editorInstanceWithBlockToolWithPasteHandler')
.render({ .render({
@ -192,7 +196,8 @@ describe('Copy pasting from Editor', function () {
data: {}, data: {},
}, },
], ],
}); })
.wait(100);
cy.get('@editorInstanceWithBlockToolWithPasteHandler') cy.get('@editorInstanceWithBlockToolWithPasteHandler')
.get('div.ce-block-with-disabled-prevent-default') .get('div.ce-block-with-disabled-prevent-default')
@ -211,6 +216,8 @@ describe('Copy pasting from Editor', function () {
context('copying', function () { context('copying', function () {
it('should copy inline fragment', function () { it('should copy inline fragment', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -225,6 +232,8 @@ describe('Copy pasting from Editor', function () {
}); });
it('should copy several blocks', function () { it('should copy several blocks', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -247,7 +256,6 @@ describe('Copy pasting from Editor', function () {
/** /**
* Need to wait for custom data as it is set asynchronously * Need to wait for custom data as it is set asynchronously
*/ */
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(0).then(function () { cy.wait(0).then(function () {
expect(clipboardData['application/x-editor-js']).not.to.be.undefined; expect(clipboardData['application/x-editor-js']).not.to.be.undefined;
@ -264,6 +272,8 @@ describe('Copy pasting from Editor', function () {
context('cutting', function () { context('cutting', function () {
it('should cut inline fragment', function () { it('should cut inline fragment', function () {
cy.createEditor({});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -278,15 +288,25 @@ describe('Copy pasting from Editor', function () {
}); });
it('should cut several blocks', function () { it('should cut several blocks', function () {
cy.get('[data-cy=editorjs]') cy.createEditor({
.get('div.ce-block') data: {
.click() blocks: [
.type('First block{enter}'); {
type: 'paragraph',
data: { text: 'First block' },
},
{
type: 'paragraph',
data: { text: 'Second block' },
},
],
},
});
cy.get('[data-cy=editorjs') cy.get('[data-cy=editorjs')
.get('div.ce-block') .get('div.ce-block')
.next() .last()
.type('Second block') .click()
.type('{movetostart}') .type('{movetostart}')
.trigger('keydown', { .trigger('keydown', {
shiftKey: true, shiftKey: true,
@ -300,7 +320,6 @@ describe('Copy pasting from Editor', function () {
/** /**
* Need to wait for custom data as it is set asynchronously * Need to wait for custom data as it is set asynchronously
*/ */
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(0).then(function () { cy.wait(0).then(function () {
expect(clipboardData['application/x-editor-js']).not.to.be.undefined; expect(clipboardData['application/x-editor-js']).not.to.be.undefined;
@ -320,15 +339,23 @@ describe('Copy pasting from Editor', function () {
it('should cut lots of blocks', function () { it('should cut lots of blocks', function () {
const numberOfBlocks = 50; const numberOfBlocks = 50;
const blocks = [];
for (let i = 0; i < numberOfBlocks; i++) { for (let i = 0; i < numberOfBlocks; i++) {
cy.get('[data-cy=editorjs]') blocks.push({
.get('div.ce-block') type: 'paragraph',
.last() data: {
.click() text: `Block ${i}`,
.type(`Block ${i}{enter}`); },
});
} }
cy.createEditor({
data: {
blocks,
},
});
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.first() .first()
@ -340,13 +367,12 @@ describe('Copy pasting from Editor', function () {
/** /**
* Need to wait for custom data as it is set asynchronously * Need to wait for custom data as it is set asynchronously
*/ */
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(0).then(function () { cy.wait(0).then(function () {
expect(clipboardData['application/x-editor-js']).not.to.be.undefined; expect(clipboardData['application/x-editor-js']).not.to.be.undefined;
const data = JSON.parse(clipboardData['application/x-editor-js']); const data = JSON.parse(clipboardData['application/x-editor-js']);
expect(data.length).to.eq(numberOfBlocks + 1); expect(data.length).to.eq(numberOfBlocks);
}); });
}); });
}); });

View file

@ -127,7 +127,8 @@ describe('Editor i18n', () => {
toolNames: toolNamesDictionary, toolNames: toolNamesDictionary,
}, },
}, },
}).as('editorInstance'); });
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click(); .click();

View file

@ -1,12 +1,14 @@
import type EditorJS from '../../../../../types/index'; import type EditorJS from '../../../../../types/index';
import Chainable = Cypress.Chainable;
/** /**
* Creates Editor instance with list of Paragraph blocks of passed texts * Creates Editor instance with list of Paragraph blocks of passed texts
* *
* @param textBlocks - list of texts for Paragraph blocks * @param textBlocks - list of texts for Paragraph blocks
*/ */
function createEditorWithTextBlocks(textBlocks: string[]): void { function createEditorWithTextBlocks(textBlocks: string[]): Chainable<EditorJS> {
cy.createEditor({ return cy.createEditor({
data: { data: {
blocks: textBlocks.map((text) => ({ blocks: textBlocks.map((text) => ({
type: 'paragraph', type: 'paragraph',

View file

@ -13,8 +13,6 @@ describe('Enter keydown', function () {
}, },
}); });
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.find('.ce-paragraph') .find('.ce-paragraph')
.click() .click()
@ -22,7 +20,6 @@ describe('Enter keydown', function () {
.wait(0) .wait(0)
.type('{enter}'); .type('{enter}');
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.find('div.ce-block') .find('div.ce-block')
.then((blocks) => { .then((blocks) => {

View file

@ -0,0 +1,149 @@
import ToolMock from '../../fixtures/tools/ToolMock';
describe('Renderer module', function () {
it('should not cause onChange firing during initial rendering', function () {
const config = {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'some text',
},
},
{
type: 'paragraph',
data: {
text: 'some other text',
},
},
],
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange: () => {},
};
cy.createEditor(config)
.as('editorInstance');
cy.spy(config, 'onChange').as('onChange');
cy.get('@onChange').should('not.be.called');
});
it('should show Stub block if block tool is not registered', function () {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'some text',
},
},
{
type: 'non-existing tool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'some other text',
},
},
],
},
})
.as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.should('have.length', 3);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.each(($el, index) => {
/**
* Check that the second block is stub
*/
if (index === 1) {
cy.wrap($el)
.find('.ce-stub')
.should('have.length', 1);
/**
* Tool title displayed
*/
cy.wrap($el)
.find('.ce-stub__title')
.should('have.text', 'non-existing tool');
}
});
});
it('should show Stub block if block tool throws error during construction', function () {
/**
* Mock of tool that triggers error during construction
*/
class ToolWithError extends ToolMock {
/**
* @param options - tool options
*/
constructor(options) {
super(options);
throw new Error('Tool error');
}
}
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'some text',
},
},
{
type: 'failedTool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'some other text',
},
},
],
},
tools: {
failedTool: ToolWithError,
},
})
.as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.should('have.length', 3);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.each(($el, index) => {
/**
* Check that the second block is stub
*/
if (index === 1) {
cy.wrap($el)
.find('.ce-stub')
.should('have.length', 1);
/**
* Tool title displayed
*/
cy.wrap($el)
.find('.ce-stub__title')
.should('have.text', 'failedTool');
}
});
});
});

View file

@ -5,6 +5,7 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged'; import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved'; import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
import type EditorJS from '../../../types/index';
/** /**
@ -132,7 +133,6 @@ describe('onChange callback', () => {
}, },
]); ]);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -483,7 +483,6 @@ describe('onChange callback', () => {
.get('div.ce-block') .get('div.ce-block')
.click(); .click();
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
cy.wait(500).then(() => { cy.wait(500).then(() => {
cy.get('@onChange').should('have.callCount', 0); cy.get('@onChange').should('have.callCount', 0);
}); });
@ -562,7 +561,6 @@ describe('onChange callback', () => {
/** /**
* Emulate tool's internal attribute mutation * Emulate tool's internal attribute mutation
*/ */
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
cy.wait(100).then(() => { cy.wait(100).then(() => {
toolWrapper.setAttribute('some-changed-attr', 'some-new-value'); toolWrapper.setAttribute('some-changed-attr', 'some-new-value');
}); });
@ -570,9 +568,86 @@ describe('onChange callback', () => {
/** /**
* Check that onChange callback was not called * Check that onChange callback was not called
*/ */
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
cy.wait(500).then(() => { cy.wait(500).then(() => {
cy.get('@onChange').should('have.callCount', 0); cy.get('@onChange').should('have.callCount', 0);
}); });
}); });
it('should be called on blocks.clear() with removed and added blocks', () => {
createEditor([
{
type: 'paragraph',
data: {
text: 'The first paragraph',
},
},
{
type: 'paragraph',
data: {
text: 'The second paragraph',
},
},
]);
cy.get<EditorJS>('@editorInstance')
.then(async editor => {
cy.wrap(editor.blocks.clear());
});
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockRemovedMutationType,
},
{
type: BlockRemovedMutationType,
},
{
type: BlockAddedMutationType,
},
]);
});
});
it('should be called on blocks.render() on non-empty editor with removed blocks', () => {
createEditor([
{
type: 'paragraph',
data: {
text: 'The first paragraph',
},
},
{
type: 'paragraph',
data: {
text: 'The second paragraph',
},
},
]);
cy.get<EditorJS>('@editorInstance')
.then(async editor => {
cy.wrap(editor.blocks.render({
blocks: [
{
type: 'paragraph',
data: {
text: 'The new paragraph',
},
},
],
}));
});
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockRemovedMutationType,
},
{
type: BlockRemovedMutationType,
},
]);
});
});
}); });

View file

@ -1,15 +1,9 @@
import type EditorJS from '../../../types/index';
import { OutputData } from '../../../types/index';
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
describe('Output sanitization', () => { describe('Output sanitization', () => {
beforeEach(function () {
cy.createEditor({}).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance) {
this.editorInstance.destroy();
}
});
context('Output should save inline formatting', () => { context('Output should save inline formatting', () => {
it('should save initial formatting for paragraph', () => { it('should save initial formatting for paragraph', () => {
cy.createEditor({ cy.createEditor({
@ -19,16 +13,21 @@ describe('Output sanitization', () => {
data: { text: '<b>Bold text</b>' }, data: { text: '<b>Bold text</b>' },
} ], } ],
}, },
}).then(async editor => { })
const output = await (editor as any).save(); .then(async editor => {
cy.wrap<OutputData>(await editor.save())
.then((output) => {
const boldText = output.blocks[0].data.text;
const boldText = output.blocks[0].data.text; expect(boldText).to.eq('<b>Bold text</b>');
});
expect(boldText).to.eq('<b>Bold text</b>'); });
});
}); });
it('should save formatting for paragraph', () => { it('should save formatting for paragraph', () => {
cy.createEditor({})
.as('editorInstance');
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.click() .click()
@ -42,16 +41,21 @@ describe('Output sanitization', () => {
.get('div.ce-block') .get('div.ce-block')
.click(); .click();
cy.get('@editorInstance').then(async editorInstance => { cy.get<EditorJS>('@editorInstance')
const output = await (editorInstance as any).save(); .then(async editorInstance => {
cy.wrap(await editorInstance.save())
.then((output) => {
const text = output.blocks[0].data.text;
const text = output.blocks[0].data.text; expect(text).to.match(/<b>This text should be bold\.(<br>)?<\/b>/);
});
expect(text).to.match(/<b>This text should be bold\.(<br>)?<\/b>/); });
});
}); });
it('should save formatting for paragraph on paste', () => { it('should save formatting for paragraph on paste', () => {
cy.createEditor({})
.as('editorInstance');
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('div.ce-block') .get('div.ce-block')
.paste({ .paste({
@ -59,13 +63,15 @@ describe('Output sanitization', () => {
'text/html': '<p>Text</p><p><b>Bold text</b></p>', 'text/html': '<p>Text</p><p><b>Bold text</b></p>',
}); });
cy.get('@editorInstance').then(async editorInstance => { cy.get<EditorJS>('@editorInstance')
const output = await (editorInstance as any).save(); .then(async editorInstance => {
cy.wrap<OutputData>(await editorInstance.save())
.then((output) => {
const boldText = output.blocks[1].data.text;
const boldText = output.blocks[1].data.text; expect(boldText).to.eq('<b>Bold text</b>');
});
expect(boldText).to.eq('<b>Bold text</b>'); });
});
}); });
}); });
}); });

View file

@ -37,20 +37,6 @@ class SomePlugin {
} }
describe('Flipper', () => { describe('Flipper', () => {
beforeEach(function () {
cy.createEditor({
tools: {
sometool: SomePlugin,
},
}).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance) {
this.editorInstance.destroy();
}
});
it('should prevent plugins event handlers from being called while keyboard navigation', () => { it('should prevent plugins event handlers from being called while keyboard navigation', () => {
const TAB_KEY_CODE = 9; const TAB_KEY_CODE = 9;
const ARROW_DOWN_KEY_CODE = 40; const ARROW_DOWN_KEY_CODE = 40;
@ -58,15 +44,23 @@ describe('Flipper', () => {
const sampleText = 'sample text'; const sampleText = 'sample text';
cy.createEditor({
tools: {
sometool: SomePlugin,
},
data: {
blocks: [
{
type: 'sometool',
data: {
},
},
],
},
});
cy.spy(SomePlugin, 'pluginInternalKeydownHandler'); cy.spy(SomePlugin, 'pluginInternalKeydownHandler');
// Insert sometool block and enter sample text
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.trigger('keydown', { keyCode: TAB_KEY_CODE });
cy.get('[data-item-name=sometool]').click();
cy.get('[data-cy=editorjs]') cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin') .get('.cdx-some-plugin')
.focus() .focus()

11
types/api/blocks.d.ts vendored
View file

@ -1,4 +1,4 @@
import {OutputData} from '../data-formats/output-data'; import {OutputBlockData, OutputData} from '../data-formats/output-data';
import {BlockToolData, ToolConfig} from '../tools'; import {BlockToolData, ToolConfig} from '../tools';
import {BlockAPI} from './block'; import {BlockAPI} from './block';
@ -103,7 +103,6 @@ export interface Blocks {
* @param {boolean?} needToFocus - flag to focus inserted Block * @param {boolean?} needToFocus - flag to focus inserted Block
* @param {boolean?} replace - should the existed Block on that index be replaced or not * @param {boolean?} replace - should the existed Block on that index be replaced or not
* @param {string} id An optional id for the new block. If omitted then the new id will be generated * @param {string} id An optional id for the new block. If omitted then the new id will be generated
*/ */
insert( insert(
type?: string, type?: string,
@ -115,6 +114,14 @@ export interface Blocks {
id?: string, id?: string,
): BlockAPI; ): BlockAPI;
/**
* Inserts several Blocks to specified index
*/
insertMany(
blocks: OutputBlockData[],
index?: number,
): BlockAPI[];
/** /**
* Creates data of an empty block with a passed type. * Creates data of an empty block with a passed type.

View file

@ -65,4 +65,4 @@ export default {
plugins: [ plugins: [
cssInjectedByJsPlugin(), cssInjectedByJsPlugin(),
], ],
}; };

View file

@ -571,10 +571,10 @@
dependencies: dependencies:
"@codexteam/icons" "^0.0.5" "@codexteam/icons" "^0.0.5"
"@editorjs/paragraph@^2.9.0": "@editorjs/paragraph@^2.10.0":
version "2.9.0" version "2.10.0"
resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.9.0.tgz#22f508b3771a4f98650e8bb37d628e230f0a378c" resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.10.0.tgz#dcf152e69738a9399b4af83262606d76cf1376e5"
integrity sha512-lI6x1eiqFx2X3KmMak6gBlimFqXhG35fQpXMxzrjIphNLr4uPsXhVbyMPimPoLUnS9rM6Q00vptXmP2QNDd0zg== integrity sha512-AzaGxR9DQAdWhx43yupBcwqtwH0WWi5jBDOCSeALIK86IYOnO6Lp4anEbH8IYmYrE/5MdnRiTwdU8/Xs8W15Nw==
dependencies: dependencies:
"@codexteam/icons" "^0.0.4" "@codexteam/icons" "^0.0.4"
@ -1649,6 +1649,17 @@ cypress-intellij-reporter@^0.0.7:
dependencies: dependencies:
mocha latest mocha latest
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"
integrity sha512-0Gf/pXjrYpTkf2aR3LAFGoxEM0KulWsMKCu+52YJB6l7GEP2RLAOAr32tcZHZiL2EWnS0vE4ollomMzGvCci0w==
dependencies:
chalk "^4.0.0"
fs-extra "^10.1.0"
safe-json-stringify "^1.2.0"
semver "^7.3.5"
tv4 "^1.3.0"
cypress@^12.9.0: cypress@^12.9.0:
version "12.9.0" version "12.9.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.9.0.tgz#e6ab43cf329fd7c821ef7645517649d72ccf0a12" resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.9.0.tgz#e6ab43cf329fd7c821ef7645517649d72ccf0a12"
@ -4443,6 +4454,11 @@ safe-buffer@^5.1.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
safe-json-stringify@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd"
integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==
safe-regex-test@^1.0.0: safe-regex-test@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295"
@ -4474,6 +4490,13 @@ semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.7, semver@^7.3.8:
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^7.3.5:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
serialize-javascript@6.0.0: serialize-javascript@6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8"
@ -4936,6 +4959,11 @@ tunnel-agent@^0.6.0:
dependencies: dependencies:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
tv4@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/tv4/-/tv4-1.3.0.tgz#d020c846fadd50c855abb25ebaecc68fc10f7963"
integrity sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==
tweetnacl@^0.14.3, tweetnacl@~0.14.0: tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5" version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"