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.
// You may want to clean this up later by importing these.
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}',
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
- `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 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` - Tools shortcuts could be used to convert one Block to another.
- `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

View file

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

View file

@ -1,6 +1,6 @@
{
"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",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
@ -44,7 +44,7 @@
"@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.7.0",
"@editorjs/paragraph": "^2.9.0",
"@editorjs/paragraph": "^2.10.0",
"@editorjs/simple-image": "^1.4.1",
"@types/node": "^18.15.11",
"chai-subset": "^1.6.0",
@ -53,6 +53,7 @@
"core-js": "3.30.0",
"cypress": "^12.9.0",
"cypress-intellij-reporter": "^0.0.7",
"cypress-terminal-report": "^5.3.2",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",

View file

@ -252,15 +252,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
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>"
* so we need to track focus events to update current input and clear cache.
*/
this.addInputEvents();
/**
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
* so we need to track focus events to update current input and clear cache.
*/
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
*

View file

@ -39,7 +39,8 @@ export default class Core {
/**
* 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) => {
onReady = resolve;
@ -50,33 +51,22 @@ export default class Core {
.then(async () => {
this.configuration = config;
await this.validate();
await this.init();
this.validate();
this.init();
await this.start();
await this.render();
_.logLabeled('I\'m ready! (ノ◕ヮ◕)ノ*:・゚✧', 'log', '', 'color: #E24A75');
const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances;
setTimeout(async () => {
await this.render();
UI.checkEmptiness();
ModificationsObserver.enable();
if ((this.configuration as EditorConfig).autofocus) {
const { BlockManager, Caret } = this.moduleInstances;
if ((this.configuration as EditorConfig).autofocus) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
BlockManager.highlightCurrentNode();
}
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
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);
onReady();
})
.catch((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
*
* @returns {Promise<void>}
*/
public async validate(): Promise<void> {
public validate(): void {
const { holderId, holder } = this.config;
if (holderId && holder) {

View file

@ -1,5 +1,5 @@
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 BlockAPI from '../../block/api';
import Module from '../../__module';
@ -32,6 +32,7 @@ export default class BlocksAPI extends Module {
stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status),
insertNewBlock: (): void => this.insertNewBlock(),
insert: this.insert,
insertMany: this.insertMany,
update: this.update,
composeBlockData: this.composeBlockData,
convert: this.convert,
@ -181,8 +182,12 @@ export default class BlocksAPI extends Module {
*
* @param {OutputData} data Saved Editor data
*/
public render(data: OutputData): Promise<void> {
this.Editor.BlockManager.clear();
public async render(data: OutputData): Promise<void> {
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);
}
@ -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"`);
}
};
/**
* 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
.mergeBlocks(targetBlock, blockToMerge)
.then(() => {
/** Restore caret position after merge */
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
targetBlock.pluginsContent.normalize();
Toolbar.close();
window.requestAnimationFrame(() => {
/** Restore caret position after merge */
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
targetBlock.pluginsContent.normalize();
Toolbar.close();
});
});
}

View file

@ -20,6 +20,7 @@ import { BlockChangedMutationType } from '../../../types/events/block/BlockChang
import { BlockChanged } from '../events';
import { clean } from '../utils/sanitizer';
import { convertStringToBlockData } from '../utils/blocks';
import PromiseQueue from '../utils/promise-queue';
/**
* @typedef {BlockManager} BlockManager
@ -244,7 +245,9 @@ export default class BlockManager extends Module {
}, this.eventsDispatcher);
if (!readOnly) {
this.bindBlockEvents(block);
window.requestIdleCallback(() => {
this.bindBlockEvents(block);
}, { timeout: 2000 });
}
return block;
@ -320,6 +323,16 @@ export default class BlockManager extends Module {
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
*
@ -433,40 +446,48 @@ export default class BlockManager extends Module {
* Remove passed Block
*
* @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 {
const index = this._blocks.indexOf(block);
public removeBlock(block: Block, addLastBlock = true): Promise<void> {
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 (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove');
}
/**
* If index is not passed and there is no block selected, show a warning
*/
if (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove');
}
block.destroy();
this._blocks.remove(index);
block.destroy();
this._blocks.remove(index);
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, block, {
index,
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, block, {
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
* 2) in api.blocks.clear we should add empty block
*/
public clear(needToAddDefaultBlock = false): void {
this._blocks.removeAll();
public async clear(needToAddDefaultBlock = false): Promise<void> {
const queue = new PromiseQueue();
this.blocks.forEach((block) => {
queue.add(async () => {
await this.removeBlock(block, false);
});
});
await queue.completed;
this.dropPointer();
if (needToAddDefaultBlock) {

View file

@ -503,13 +503,10 @@ export default class Caret extends Module {
sel.expandToTag(shadowCaret as HTMLElement);
setTimeout(() => {
const newRange = document.createRange();
const newRange = document.createRange();
newRange.selectNode(shadowCaret);
newRange.extractContents();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 50);
newRange.selectNode(shadowCaret);
newRange.extractContents();
}
/**
@ -534,7 +531,7 @@ export default class Caret extends Module {
fragment.appendChild(new Text());
}
const lastChild = fragment.lastChild;
const lastChild = fragment.lastChild as ChildNode;
range.deleteContents();
range.insertNode(fragment);
@ -542,7 +539,11 @@ export default class Caret extends Module {
/** Cross-browser caret insertion */
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.addRange(newRange);

View file

@ -479,9 +479,14 @@ export default class Paste extends Module {
private handlePasteEvent = async (event: ClipboardEvent): Promise<void> => {
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 (
!BlockManager.currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
!currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
) {
return;
}
@ -489,7 +494,7 @@ export default class Paste extends Module {
/**
* 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;
}

View file

@ -1,119 +1,107 @@
import Module from '../__module';
import * as _ from '../utils';
import { OutputBlockData } from '../../../types';
import BlockTool from '../tools/block';
import type { BlockId, BlockToolData, OutputBlockData } from '../../../types';
import type BlockTool from '../tools/block';
import type { StubData } from '../../tools/stub';
import Block from '../block';
/**
* Editor.js Renderer Module
*
* @module Renderer
* @author CodeX Team
* @version 2.0.0
* Module that responsible for rendering Blocks on editor initialization
*/
export default class Renderer extends Module {
/**
* @typedef {object} RendererBlocks
* @property {string} type - tool name
* @property {object} data - tool data
*/
/**
* @example
* Renders passed blocks as one batch
*
* blocks: [
* {
* id : 'oDe-EVrGWA',
* type : 'paragraph',
* data : {
* text : 'Hello from Codex!'
* }
* },
* {
* id : 'Ld5BJjJCHs',
* type : 'paragraph',
* data : {
* text : 'Leave feedback if you like it!'
* }
* },
* ]
* @param blocksData - blocks to render
*/
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
*
* @param {OutputBlockData[]} blocks - blocks to render
*/
public async render(blocks: OutputBlockData[]): Promise<void> {
const chainData = blocks.map((block) => ({ function: (): Promise<void> => this.insertBlock(block) }));
/**
* Create Blocks instances
*/
const blocks = blocksData.map(({ type: tool, data, tunes, id }) => {
if (Tools.available.has(tool) === false) {
_.logLabeled(`Tool «${tool}» is not found. Check 'tools' property at the Editor.js config.`, 'warn');
/**
* Disable onChange callback on render to not to spam those events
*/
this.Editor.ModificationsObserver.disable();
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
}
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,
});
}
/**
* 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,
return block;
});
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}
*/
private makeOutput(allExtractedData): OutputData {
let totalTime = 0;
const blocks = [];
_.log('[Editor.js saving]:', 'groupCollapsed');
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 {
allExtractedData.forEach(({ id, tool, data, tunes, isValid }) => {
if (!isValid) {
_.log(`Block «${tool}» skipped because saved data is invalid`);
_.log(undefined, 'groupEnd');
return;
}
@ -113,9 +98,6 @@ export default class Saver extends Module {
blocks.push(output);
});
_.log('Total', 'log', totalTime);
_.log(undefined, 'groupEnd');
return {
time: +new Date(),
blocks,

View file

@ -103,8 +103,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* 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
@ -155,18 +156,27 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Public interface for accessing the Toolbox
*/
public get toolbox(): {
opened: boolean;
opened: boolean | undefined; // undefined is for the case when Toolbox is not initialized yet
close: () => void;
open: () => void;
toggle: () => void;
hasFocus: () => boolean;
hasFocus: () => boolean | undefined;
} {
return {
opened: this.toolboxInstance.opened,
close: (): void => {
this.toolboxInstance.close();
opened: this.toolboxInstance?.opened,
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.
*/
@ -174,8 +184,19 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.toolboxInstance.open();
},
toggle: (): void => this.toolboxInstance.toggle(),
hasFocus: (): boolean => this.toolboxInstance.hasFocus(),
toggle: () => {
/**
* 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 {
if (!readOnlyEnabled) {
this.drawUI();
this.enableModuleBindings();
window.requestIdleCallback(() => {
this.drawUI();
this.enableModuleBindings();
}, { timeout: 2000 });
} else {
this.destroy();
this.Editor.BlockSettings.destroy();
@ -225,6 +248,15 @@ export default class Toolbar extends Module<ToolbarNodes> {
* @param block - block to move Toolbar near it
*/
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
*/
@ -294,7 +326,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/** Close components */
this.blockActions.hide();
this.toolboxInstance.close();
this.toolboxInstance?.close();
this.Editor.BlockSettings.close();
}
@ -454,7 +486,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
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();
if (this.toolboxInstance.opened) {
if (this.toolboxInstance?.opened) {
this.toolboxInstance.close();
}
@ -496,7 +528,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* 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;
}

View file

@ -122,7 +122,9 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) {
this.make();
window.requestIdleCallback(() => {
this.make();
}, { timeout: 2000 });
} else {
this.destroy();
this.Editor.ConversionToolbar.destroy();
@ -359,8 +361,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* 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

View file

@ -22,7 +22,6 @@ interface UINodes {
holder: HTMLElement;
wrapper: HTMLElement;
redactor: HTMLElement;
loader: HTMLElement;
}
/**
@ -49,14 +48,13 @@ export default class UI extends Module<UINodes> {
*/
public get CSS(): {
editorWrapper: string; editorWrapperNarrow: string; editorZone: string; editorZoneHidden: string;
editorLoader: string; editorEmpty: string; editorRtlFix: string;
editorEmpty: string; editorRtlFix: string;
} {
return {
editorWrapper: 'codex-editor',
editorWrapperNarrow: 'codex-editor--narrow',
editorZone: 'codex-editor__redactor',
editorZoneHidden: 'codex-editor__redactor--hidden',
editorLoader: 'codex-editor__loader',
editorEmpty: 'codex-editor--empty',
editorRtlFix: 'codex-editor--rtl',
};
@ -115,23 +113,6 @@ export default class UI extends Module<UINodes> {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 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
*/
@ -146,11 +127,6 @@ export default class UI extends Module<UINodes> {
*/
this.make();
/**
* Loader for rendering process
*/
this.addLoader();
/**
* 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
*
* @todo Forced layout. Get rid of this feature
*/
if (this.nodes.holder.offsetWidth < this.contentRect.width) {
this.nodes.wrapper.classList.add(this.CSS.editorWrapperNarrow);
@ -684,12 +662,7 @@ export default class UI extends Module<UINodes> {
* Select clicked Block as Current
*/
try {
/**
* Renew Current Block. Use RAF to wait until Selection is set.
*/
window.requestAnimationFrame(() => {
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
});
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
/**
* 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
*/
public static getRangeFromSelection(selection: Selection): Range {
public static getRangeFromSelection(selection: Selection): Range | 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} fallback - callback that fires in case of errors
* @returns {Promise}
* @deprecated use PromiseQueue.ts instead
*/
export async function sequence(
chains: ChainData[],

View file

@ -94,6 +94,12 @@ export default class EventsDispatcher<EventMap> {
* @param callback - event handler
*/
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++) {
if (this.subscribers[eventName][i] === callback) {
delete this.subscribers[eventName][i];
@ -107,6 +113,6 @@ export default class EventsDispatcher<EventMap> {
* clears subscribers list
*/
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 {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 3.5em 0;
margin: 17px 0;
border-radius: 3px;
background: #fcf7f7;
color: #b46262;
padding: 12px 18px;
margin: 10px 0;
border-radius: 10px;
background: var(--bg-light);
border: 1px solid var(--color-line-gray);
color: var(--grayText);
font-size: 14px;
svg {
width: var(--icon-size);
height: var(--icon-size);
}
&__info {
margin-left: 20px;
margin-left: 14px;
}
&__title {
margin-bottom: 3px;
font-weight: 600;
font-size: 18px;
font-weight: 500;
text-transform: capitalize;
}
&__subtitle {
font-size: 16px;
}
}

View file

@ -11,10 +11,6 @@
}
&__redactor {
&--hidden {
display: none;
}
/**
* Workaround firefox bug: empty content editable elements has collapsed height
* 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 {
position: absolute;
height: 1px;
@ -107,29 +81,21 @@
path {
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 {
opacity: 0 !important;
}
@keyframes editor-loader-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.ce-scroll-locked {
overflow: hidden;
}

View file

@ -1,5 +1,6 @@
import $ from '../../components/dom';
import { API, BlockTool, BlockToolConstructorOptions, BlockToolData } from '../../../types';
import { IconWarning } from '@codexteam/icons';
export interface StubData extends BlockToolData {
title: string;
@ -92,7 +93,7 @@ export default class Stub implements BlockTool {
*/
private make(): HTMLElement {
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 title = $.make('div', this.CSS.title, {
textContent: this.title,

View file

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

View file

@ -61,7 +61,7 @@ Cypress.Commands.add('paste', {
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: {
setData: (type: string, data: any): void => {
console.log(type, data);
clipboardData[type] = data;
},
},
@ -105,7 +104,6 @@ Cypress.Commands.add('cut', { prevSubject: true }, (subject) => {
}), {
clipboardData: {
setData: (type: string, data: any): void => {
console.log(type, data);
clipboardData[type] = data;
},
},
@ -122,9 +120,10 @@ Cypress.Commands.add('cut', { prevSubject: true }, (subject) => {
* @param data data to render
*/
Cypress.Commands.add('render', { prevSubject: true }, (subject: EditorJS, data: OutputData) => {
subject.render(data);
return cy.wrap(subject);
return cy.wrap(subject.render(data))
.then(() => {
return cy.wrap(subject);
});
});
@ -154,5 +153,5 @@ Cypress.Commands.add('selectText', {
document.getSelection().removeAllRanges();
document.getSelection().addRange(range);
return subject;
return cy.wrap(subject);
});

View file

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

View file

@ -63,9 +63,7 @@ describe('api.blocks', () => {
it('should update block in DOM', () => {
cy.createEditor({
data: editorDataMock,
}).as('editorInstance');
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
}).then((editor) => {
const idToUpdate = firstBlock.id;
const newBlockData = {
text: 'Updated text',
@ -75,10 +73,7 @@ describe('api.blocks', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.invoke('text')
.then(blockText => {
expect(blockText).to.be.eq(newBlockData.text);
});
.should('have.text', newBlockData.text);
});
});
@ -88,9 +83,7 @@ describe('api.blocks', () => {
it('should update block in saved data', () => {
cy.createEditor({
data: editorDataMock,
}).as('editorInstance');
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
}).then((editor) => {
const idToUpdate = firstBlock.id;
const newBlockData = {
text: 'Updated text',
@ -98,10 +91,14 @@ describe('api.blocks', () => {
editor.blocks.update(idToUpdate, newBlockData);
const output = await editor.save();
const text = output.blocks[0].data.text;
// wait a little since some tools (paragraph) could have async hydration
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', () => {
cy.createEditor({
data: editorDataMock,
}).as('editorInstance');
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
}).then((editor) => {
const idToUpdate = 'wrong-id-123';
const newBlockData = {
text: 'Updated text',
@ -138,9 +133,7 @@ describe('api.blocks', () => {
it('should preserve block id if it is passed', function () {
cy.createEditor({
data: editorDataMock,
}).as('editorInstance');
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
}).then((editor) => {
const type = 'paragraph';
const data = { text: 'codex' };
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 () {
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,
],
},
}).as('editorInstance');
}).then((editor) => {
const { convert } = editor.blocks;
/**
* Call the 'convert' api method
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { convert } = editor.blocks;
convert(existingBlock.id, 'convertableTool');
convert(existingBlock.id, 'convertableTool');
});
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers -- wait for block to be converted
cy.wait(100);
/**
* Check that block was converted
*/
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);
// wait for block to be converted
cy.wait(100).then(() => {
/**
* Check that block was converted
*/
editor.save().then(( { blocks }) => {
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 () {
cy.createEditor({}).as('editorInstance');
/**
* Call the 'convert' api method with nonexisting Block id
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
cy.createEditor({})
.then((editor) => {
/**
* Call the 'convert' api method with nonexisting Block id
*/
const fakeId = 'WRNG_ID';
const { convert } = editor.blocks;
@ -262,20 +291,17 @@ describe('api.blocks', () => {
existingBlock,
],
},
}).as('editorInstance');
}).then((editor) => {
/**
* Call the 'convert' api method with nonexisting tool name
*/
const nonexistingToolName = 'WRNG_TOOL_NAME';
const { convert } = editor.blocks;
/**
* 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);
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 () {
@ -304,19 +330,16 @@ describe('api.blocks', () => {
existingBlock,
],
},
}).as('editorInstance');
}).then((editor) => {
/**
* Call the 'convert' api method with tool that does not provide "conversionConfig"
*/
const { convert } = editor.blocks;
/**
* 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');
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 { nanoid } from 'nanoid';
import type EditorJS from '../../../types/index';
describe('Block ids', () => {
beforeEach(function () {
it('Should generate unique block ids for new blocks', () => {
cy.createEditor({
tools: {
header: Header,
},
}).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance) {
this.editorInstance.destroy();
}
});
it('Should generate unique block ids for new blocks', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
@ -42,8 +35,8 @@ describe('Block ids', () => {
.click()
.type('Header');
cy.get('@editorInstance')
.then(async (editor: any) => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const data = await editor.save();
data.blocks.forEach(block => {
@ -53,6 +46,9 @@ describe('Block ids', () => {
});
it('should preserve passed ids', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [
{
id: nanoid(),
@ -70,19 +66,13 @@ describe('Block ids', () => {
},
];
cy.get('@editorInstance')
cy.get<EditorJS>('@editorInstance')
.render({
blocks,
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.first()
.click()
.type('{movetoend} Some more text');
cy.get('@editorInstance')
.then(async (editor: any) => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const data = await editor.save();
data.blocks.forEach((block, index) => {
@ -92,6 +82,9 @@ describe('Block ids', () => {
});
it('should preserve passed ids if blocks were added', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [
{
id: nanoid(),
@ -109,7 +102,7 @@ describe('Block ids', () => {
},
];
cy.get('@editorInstance')
cy.get<EditorJS>('@editorInstance')
.render({
blocks,
});
@ -122,16 +115,20 @@ describe('Block ids', () => {
.next()
.type('Middle block');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
expect(data.blocks[0].id).to.eq(blocks[0].id);
expect(data.blocks[2].id).to.eq(blocks[1].id);
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
cy.wrap(await editor.save())
.then((data) => {
expect(data.blocks[0].id).to.eq(blocks[0].id);
expect(data.blocks[2].id).to.eq(blocks[1].id);
});
});
});
it('should be stored at the Block wrapper\'s data-id attribute', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [
{
id: nanoid(),
@ -149,7 +146,7 @@ describe('Block ids', () => {
},
];
cy.get('@editorInstance')
cy.get<EditorJS>('@editorInstance')
.render({
blocks,
});

View file

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

View file

@ -1,12 +1,14 @@
import type EditorJS from '../../../../../types/index';
import Chainable = Cypress.Chainable;
/**
* 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({
function createEditorWithTextBlocks(textBlocks: string[]): Chainable<EditorJS> {
return cy.createEditor({
data: {
blocks: textBlocks.map((text) => ({
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]')
.find('.ce-paragraph')
.click()
@ -22,7 +20,6 @@ describe('Enter keydown', function () {
.wait(0)
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.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 { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
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]')
.get('div.ce-block')
.click()
@ -483,7 +483,6 @@ describe('onChange callback', () => {
.get('div.ce-block')
.click();
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
cy.wait(500).then(() => {
cy.get('@onChange').should('have.callCount', 0);
});
@ -562,7 +561,6 @@ describe('onChange callback', () => {
/**
* Emulate tool's internal attribute mutation
*/
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
cy.wait(100).then(() => {
toolWrapper.setAttribute('some-changed-attr', 'some-new-value');
});
@ -570,9 +568,86 @@ describe('onChange callback', () => {
/**
* 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.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 */
describe('Output sanitization', () => {
beforeEach(function () {
cy.createEditor({}).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance) {
this.editorInstance.destroy();
}
});
context('Output should save inline formatting', () => {
it('should save initial formatting for paragraph', () => {
cy.createEditor({
@ -19,16 +13,21 @@ describe('Output sanitization', () => {
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', () => {
cy.createEditor({})
.as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
@ -42,16 +41,21 @@ describe('Output sanitization', () => {
.get('div.ce-block')
.click();
cy.get('@editorInstance').then(async editorInstance => {
const output = await (editorInstance as any).save();
cy.get<EditorJS>('@editorInstance')
.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', () => {
cy.createEditor({})
.as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.paste({
@ -59,13 +63,15 @@ describe('Output sanitization', () => {
'text/html': '<p>Text</p><p><b>Bold text</b></p>',
});
cy.get('@editorInstance').then(async editorInstance => {
const output = await (editorInstance as any).save();
cy.get<EditorJS>('@editorInstance')
.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', () => {
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', () => {
const TAB_KEY_CODE = 9;
const ARROW_DOWN_KEY_CODE = 40;
@ -58,15 +44,23 @@ describe('Flipper', () => {
const sampleText = 'sample text';
cy.createEditor({
tools: {
sometool: SomePlugin,
},
data: {
blocks: [
{
type: 'sometool',
data: {
},
},
],
},
});
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]')
.get('.cdx-some-plugin')
.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 {BlockAPI} from './block';
@ -103,7 +103,6 @@ export interface Blocks {
* @param {boolean?} needToFocus - flag to focus inserted Block
* @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
*/
insert(
type?: string,
@ -115,6 +114,14 @@ export interface Blocks {
id?: string,
): BlockAPI;
/**
* Inserts several Blocks to specified index
*/
insertMany(
blocks: OutputBlockData[],
index?: number,
): BlockAPI[];
/**
* Creates data of an empty block with a passed type.

View file

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

View file

@ -571,10 +571,10 @@
dependencies:
"@codexteam/icons" "^0.0.5"
"@editorjs/paragraph@^2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.9.0.tgz#22f508b3771a4f98650e8bb37d628e230f0a378c"
integrity sha512-lI6x1eiqFx2X3KmMak6gBlimFqXhG35fQpXMxzrjIphNLr4uPsXhVbyMPimPoLUnS9rM6Q00vptXmP2QNDd0zg==
"@editorjs/paragraph@^2.10.0":
version "2.10.0"
resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.10.0.tgz#dcf152e69738a9399b4af83262606d76cf1376e5"
integrity sha512-AzaGxR9DQAdWhx43yupBcwqtwH0WWi5jBDOCSeALIK86IYOnO6Lp4anEbH8IYmYrE/5MdnRiTwdU8/Xs8W15Nw==
dependencies:
"@codexteam/icons" "^0.0.4"
@ -1649,6 +1649,17 @@ cypress-intellij-reporter@^0.0.7:
dependencies:
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:
version "12.9.0"
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"
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:
version "1.0.0"
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:
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:
version "6.0.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8"
@ -4936,6 +4959,11 @@ tunnel-agent@^0.6.0:
dependencies:
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:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"