Merge branch 'next' into feature/issue2092-allow-block-tool-render-method-to-return-null

This commit is contained in:
trevrdspcdev 2023-10-03 08:55:23 -03:00 committed by GitHub
commit fb1302df06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1528 additions and 816 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

@ -1,14 +1,35 @@
# Changelog
### 2.29.0
- `Fix` — Passing an empty array via initial data or `blocks.render()` won't break the editor
- `Fix` — Layout did not shrink when a large document cleared in Chrome
- `Fix` — Multiple Tooltip elements creation fixed
- `Fix` — When the focusing Block is out of the viewport, the page will be scrolled.
### 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` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want to access a Block's element by id.
- `New` - The `blocks.convert(blockId, newType)` API method was added. It allows to convert existing Block to a Block of another type.
- `New` - The `blocks.insertMany()` API method added. It allows to insert several Blocks to the 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` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of the 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 is simplified.
- `Improvement` - If some Block's tool throws an error during construction, we will show Stub block instead of skipping it during render
- `Improvement` - Call of `blocks.clear()` now will trigger onChange with "block-removed" event for all removed blocks.
- `Improvement` - The `blocks.clear()` now can be awaited.
- `Improvement` - `BlockMutationType` and `BlockMutationEvent` types exported
- `Improvement` - `blocks.update(id, data)` now can accept partial data object — it will update only passed properties, others will remain the same.
- `Improvement` - `blocks.update(id, data)` now will trigger onChange with only `block-change` event.
- `Improvement` - `blocks.update(id, data)` will return a promise with BlockAPI object of the changed block.
### 2.27.2

View file

@ -98,6 +98,8 @@
<script type="module">
import EditorJS from './src/codex.ts';
window.EditorJS = EditorJS;
/**
* To initialize the Editor, create a new instance with configuration object
* @see docs/installation.md for mode details
@ -404,6 +406,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.29.0-rc.1",
"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

@ -10,6 +10,7 @@ import '@babel/register';
import './components/polyfills';
import Core from './components/core';
import * as _ from './components/utils';
import { destroy as destroyTooltip } from './components/utils/tooltip';
declare const VERSION: string;
@ -67,6 +68,9 @@ export default class EditorJS {
*/
this.isReady = editor.isReady.then(() => {
this.exportAPI(editor);
/**
* @todo pass API as an argument. It will allow to use Editor's API when editor is ready
*/
onReady();
});
}
@ -87,6 +91,8 @@ export default class EditorJS {
moduleInstance.listeners.removeAll();
});
destroyTooltip();
editor = null;
for (const field in this) {

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,62 @@ export default class Blocks {
}
}
/**
* Replaces block under passed index with passed block
*
* @param index - index of existed block
* @param block - new block
*/
public replace(index: number, block: Block): void {
if (this.blocks[index] === undefined) {
throw Error('Incorrect index');
}
const prevBlock = this.blocks[index];
prevBlock.holder.replaceWith(block.holder);
this.blocks[index] = block;
}
/**
* 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
*
@ -267,7 +323,7 @@ export default class Blocks {
* @param {number} index Block index
* @returns {Block}
*/
public get(index: number): Block {
public get(index: number): Block | undefined {
return this.blocks[index];
}

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';
@ -18,7 +18,7 @@ export default class BlocksAPI extends Module {
*/
public get methods(): Blocks {
return {
clear: (): void => this.clear(),
clear: (): Promise<void> => this.clear(),
render: (data: OutputData): Promise<void> => this.render(data),
renderFromHTML: (data: string): Promise<void> => this.renderFromHTML(data),
delete: (index?: number): void => this.delete(index),
@ -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,
@ -171,8 +172,8 @@ export default class BlocksAPI extends Module {
/**
* Clear Editor's area
*/
public clear(): void {
this.Editor.BlockManager.clear(true);
public async clear(): Promise<void> {
await this.Editor.BlockManager.clear(true);
this.Editor.InlineToolbar.close();
}
@ -181,10 +182,21 @@ 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');
}
return this.Editor.Renderer.render(data.blocks);
/**
* Semantic meaning of the "render" method: "Display the new document over the existing one that stays unchanged"
* So we need to disable modifications observer temporarily
*/
this.Editor.ModificationsObserver.disable();
await this.Editor.BlockManager.clear();
await this.Editor.Renderer.render(data.blocks);
this.Editor.ModificationsObserver.enable();
}
/**
@ -292,26 +304,19 @@ export default class BlocksAPI extends Module {
* @param id - id of the block to update
* @param data - the new data
*/
public update = (id: string, data: BlockToolData): void => {
public update = async (id: string, data: Partial<BlockToolData>): Promise<BlockAPIInterface> => {
const { BlockManager } = this.Editor;
const block = BlockManager.getBlockById(id);
if (!block) {
_.log('blocks.update(): Block with passed id was not found', 'warn');
return;
if (block === undefined) {
throw new Error(`Block with id "${id}" not found`);
}
const blockIndex = BlockManager.getBlockIndex(block);
const updatedBlock = await BlockManager.update(block, data);
BlockManager.insert({
id: block.id,
tool: block.name,
data,
index: blockIndex,
replace: true,
tunes: block.tunes,
});
// we cast to any because our BlockAPI has no "new" signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (BlockAPI as any)(updatedBlock);
};
/**
@ -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

@ -2,16 +2,12 @@ import { Tooltip as ITooltip } from '../../../../types/api';
import type { TooltipOptions, TooltipContent } from 'codex-tooltip/types';
import Module from '../../__module';
import { ModuleConfig } from '../../../types-internal/module-config';
import Tooltip from '../../utils/tooltip';
import * as tooltip from '../../utils/tooltip';
/**
* @class TooltipAPI
* @classdesc Tooltip API
*/
export default class TooltipAPI extends Module {
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param moduleConfiguration - Module Configuration
@ -23,15 +19,6 @@ export default class TooltipAPI extends Module {
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
* Destroy Module
*/
public destroy(): void {
this.tooltip.destroy();
}
/**
@ -59,14 +46,14 @@ export default class TooltipAPI extends Module {
* @param {TooltipOptions} options - tooltip options
*/
public show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.tooltip.show(element, content, options);
tooltip.show(element, content, options);
}
/**
* Method hides tooltip on HTML page
*/
public hide(): void {
this.tooltip.hide();
tooltip.hide();
}
/**
@ -77,6 +64,6 @@ export default class TooltipAPI extends Module {
* @param {TooltipOptions} options - tooltip options
*/
public onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.tooltip.onHover(element, content, options);
tooltip.onHover(element, content, options);
}
}

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,46 @@ 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);
}
/**
* Update Block data.
*
* Currently we don't have an 'update' method in the Tools API, so we just create a new block with the same id and type
* Should not trigger 'block-removed' or 'block-added' events
*
* @param block - block to update
* @param data - new data
*/
public async update(block: Block, data: Partial<BlockToolData>): Promise<Block> {
const existingData = await block.data;
const newBlock = this.composeBlock({
id: block.id,
tool: block.name,
data: Object.assign({}, existingData, data),
tunes: block.tunes,
});
const blockIndex = this.getBlockIndex(block);
this._blocks.replace(blockIndex, newBlock);
this.blockDidMutated(BlockChangedMutationType, newBlock, {
index: blockIndex,
});
return newBlock;
}
/**
* Replace passed Block with the new one with specified Tool and data
*
@ -433,40 +476,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;
}
}
/**
@ -536,13 +587,28 @@ export default class BlockManager extends Module {
return this.insert({ data });
}
/**
* Returns Block by passed index
*
* If we pass -1 as index, the last block will be returned
* There shouldn't be a case when there is no blocks at all at least one always should exist
*/
public getBlockByIndex(index: -1): Block;
/**
* Returns Block by passed index.
*
* Could return undefined if there is no block with such index
*/
public getBlockByIndex(index: number): Block | undefined;
/**
* Returns Block by passed index
*
* @param {number} index - index to get. -1 to get last
* @returns {Block}
*/
public getBlockByIndex(index): Block {
public getBlockByIndex(index: number): Block | undefined {
if (index === -1) {
index = this._blocks.length - 1;
}
@ -804,8 +870,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

@ -304,16 +304,17 @@ export default class Caret extends Module {
* @param {number} offset - offset
*/
public set(element: HTMLElement, offset = 0): void {
const scrollOffset = 30;
const { top, bottom } = Selection.setCursor(element, offset);
/** If new cursor position is not visible, scroll to it */
const { innerHeight } = window;
/**
* If new cursor position is not visible, scroll to it
*/
if (top < 0) {
window.scrollBy(0, top);
}
if (bottom > innerHeight) {
window.scrollBy(0, bottom - innerHeight);
window.scrollBy(0, top - scrollOffset);
} else if (bottom > innerHeight) {
window.scrollBy(0, bottom - innerHeight + scrollOffset);
}
}
@ -503,13 +504,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 +532,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 +540,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,111 @@
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) }));
if (blocksData.length === 0) {
BlockManager.insert();
} else {
/**
* 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,
});
}
return block;
});
/**
* Insert batch of Blocks
*/
BlockManager.insertMany(blocks);
}
/**
* Wait till browser will render inserted Blocks and resolve a promise
*/
window.requestIdleCallback(() => {
resolve();
}, { timeout: 2000 });
});
}
/**
* Get plugin instance
* Add plugin instance to BlockManager
* Insert block to working zone
* Create data for the Stub Tool that will be used instead of unavailable tool
*
* @param {object} item - Block data to insert
* @returns {Promise<void>}
* @param tool - unavailable tool name to stub
* @param data - data of unavailable block
* @param [id] - id of unavailable block
*/
public async insertBlock(item: OutputBlockData): Promise<void> {
const { Tools, BlockManager } = this.Editor;
const { type: tool, data, tunes, id } = item;
private composeStubDataForTool(tool: string, data: BlockToolData, id?: BlockId): StubData {
const { Tools } = this.Editor;
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);
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;
}
} 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;
_.log(`Tool «${tool}» is not found. Check 'tools' property at your initial Editor.js config.`, 'warn');
}
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

@ -3,7 +3,7 @@ import $ from '../../dom';
import * as _ from '../../utils';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Tooltip from '../../utils/tooltip';
import * as tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import Block from '../../block';
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
@ -91,11 +91,6 @@ interface ToolbarNodes {
* @property {Element} nodes.defaultSettings - Default Settings section of Settings Panel
*/
export default class Toolbar extends Module<ToolbarNodes> {
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* Block near which we display the Toolbox
*/
@ -103,8 +98,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
@ -117,7 +113,6 @@ export default class Toolbar extends Module<ToolbarNodes> {
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
@ -155,18 +150,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 +178,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 +225,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 +242,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,8 +320,16 @@ export default class Toolbar extends Module<ToolbarNodes> {
/** Close components */
this.blockActions.hide();
this.toolboxInstance.close();
this.toolboxInstance?.close();
this.Editor.BlockSettings.close();
this.reset();
}
/**
* Reset the Toolbar position to prevent DOM height growth, for example after blocks deletion
*/
private reset(): void {
this.nodes.wrapper.style.top = 'unset';
}
/**
@ -305,16 +339,13 @@ export default class Toolbar extends Module<ToolbarNodes> {
* This flag allows to open Toolbar without Actions.
*/
private open(withBlockActions = true): void {
_.delay(() => {
this.nodes.wrapper.classList.add(this.CSS.toolbarOpened);
this.nodes.wrapper.classList.add(this.CSS.toolbarOpened);
if (withBlockActions) {
this.blockActions.show();
} else {
this.blockActions.hide();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 50)();
if (withBlockActions) {
this.blockActions.show();
} else {
this.blockActions.hide();
}
}
/**
@ -350,7 +381,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.actions, this.nodes.plusButton);
this.readOnlyMutableListeners.on(this.nodes.plusButton, 'click', () => {
this.tooltip.hide(true);
tooltip.hide(true);
this.plusButtonClicked();
}, false);
@ -364,7 +395,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
textContent: '⇥ Tab',
}));
this.tooltip.onHover(this.nodes.plusButton, tooltipContent, {
tooltip.onHover(this.nodes.plusButton, tooltipContent, {
hidingDelay: 400,
});
@ -380,7 +411,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.actions, this.nodes.settingsToggler);
this.tooltip.onHover(
tooltip.onHover(
this.nodes.settingsToggler,
I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'),
{
@ -454,7 +485,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
this.toolboxInstance.toggle();
this.toolboxInstance?.toggle();
}
/**
@ -476,11 +507,11 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.settingsTogglerClicked();
if (this.toolboxInstance.opened) {
if (this.toolboxInstance?.opened) {
this.toolboxInstance.close();
}
this.tooltip.hide(true);
tooltip.hide(true);
}, true);
/**
@ -496,7 +527,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;
}
@ -561,6 +592,5 @@ export default class Toolbar extends Module<ToolbarNodes> {
if (this.toolboxInstance) {
this.toolboxInstance.destroy();
}
this.tooltip.destroy();
}
}

View file

@ -7,7 +7,7 @@ import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Shortcuts from '../../utils/shortcuts';
import Tooltip from '../../utils/tooltip';
import * as tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import InlineTool from '../../tools/inline';
import { CommonInternalSettings } from '../../tools/base';
@ -97,10 +97,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
private flipper: Flipper = null;
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param moduleConfiguration - Module Configuration
@ -112,7 +108,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
@ -122,7 +117,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();
@ -155,52 +152,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.Editor.Toolbar.close();
}
/**
* Move Toolbar to the selected text
*/
public move(): void {
const selectionRect = SelectionUtils.rect as DOMRect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.left,
y: selectionRect.y +
selectionRect.height -
// + window.scrollY
wrapperOffset.top +
this.toolbarVerticalMargin,
};
/**
* If we know selections width, place InlineToolbar to center
*/
if (selectionRect.width) {
newCoords.x += Math.floor(selectionRect.width / 2);
}
/**
* Inline Toolbar has -50% translateX, so we need to check real coords to prevent overflowing
*/
const realLeftCoord = newCoords.x - this.width / 2;
const realRightCoord = newCoords.x + this.width / 2;
/**
* By default, Inline Toolbar has top-corner at the center
* We are adding a modifiers for to move corner to the left or right
*/
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarLeftOriented,
realLeftCoord < this.Editor.UI.contentRect.left
);
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarRightOriented,
realRightCoord > this.Editor.UI.contentRect.right
);
this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px';
this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px';
}
/**
* Hides Inline Toolbar
*/
@ -229,6 +180,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
});
this.reset();
this.opened = false;
this.flipper.deactivate();
@ -302,7 +254,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
this.removeAllNodes();
this.tooltip.destroy();
}
/**
@ -359,8 +310,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
@ -369,6 +323,66 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.enableFlipper();
}
/**
* Move Toolbar to the selected text
*/
private move(): void {
const selectionRect = SelectionUtils.rect as DOMRect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.left,
y: selectionRect.y +
selectionRect.height -
// + window.scrollY
wrapperOffset.top +
this.toolbarVerticalMargin,
};
/**
* If we know selections width, place InlineToolbar to center
*/
if (selectionRect.width) {
newCoords.x += Math.floor(selectionRect.width / 2);
}
/**
* Inline Toolbar has -50% translateX, so we need to check real coords to prevent overflowing
*/
const realLeftCoord = newCoords.x - this.width / 2;
const realRightCoord = newCoords.x + this.width / 2;
/**
* By default, Inline Toolbar has top-corner at the center
* We are adding a modifiers for to move corner to the left or right
*/
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarLeftOriented,
realLeftCoord < this.Editor.UI.contentRect.left
);
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarRightOriented,
realRightCoord > this.Editor.UI.contentRect.right
);
this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px';
this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px';
}
/**
* Clear orientation classes and reset position
*/
private reset(): void {
this.nodes.wrapper.classList.remove(
this.CSS.inlineToolbarLeftOriented,
this.CSS.inlineToolbarRightOriented
);
this.nodes.wrapper.style.left = 'unset';
this.nodes.wrapper.style.top = 'unset';
}
/**
* Need to show Inline Toolbar or not
*/
@ -460,7 +474,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
});
if (_.isMobileScreen() === false ) {
this.tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), {
tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), {
placement: 'top',
hidingDelay: 100,
});
@ -589,7 +603,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
if (_.isMobileScreen() === false ) {
this.tooltip.onHover(button, tooltipContent, {
tooltip.onHover(button, tooltipContent, {
placement: 'top',
hidingDelay: 100,
});

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);
@ -332,11 +310,17 @@ export default class UI extends Module<UINodes> {
this.readOnlyMutableListeners.on(this.nodes.redactor, 'mousedown', (event: MouseEvent | TouchEvent) => {
this.documentTouched(event);
}, true);
}, {
capture: true,
passive: true,
});
this.readOnlyMutableListeners.on(this.nodes.redactor, 'touchstart', (event: MouseEvent | TouchEvent) => {
this.documentTouched(event);
}, true);
}, {
capture: true,
passive: true,
});
this.readOnlyMutableListeners.on(document, 'keydown', (event: KeyboardEvent) => {
this.documentKeydown(event);
@ -501,7 +485,9 @@ export default class UI extends Module<UINodes> {
if (BlockSelection.anyBlockSelected && !Selection.isSelectionExists) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
Caret.setToBlock(BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true), Caret.positions.START);
const newBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(newBlock, Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection(event);
@ -684,12 +670,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
@ -721,17 +702,10 @@ export default class UI extends Module<UINodes> {
* - otherwise, add a new empty Block and set a Caret to that
*/
private redactorClicked(event: MouseEvent): void {
const { BlockSelection } = this.Editor;
if (!Selection.isCollapsed) {
return;
}
const stopPropagation = (): void => {
event.stopImmediatePropagation();
event.stopPropagation();
};
/**
* case when user clicks on anchor element
* if it is clicked via ctrl key, then we open new window with url
@ -740,7 +714,8 @@ export default class UI extends Module<UINodes> {
const ctrlKey = event.metaKey || event.ctrlKey;
if ($.isAnchor(element) && ctrlKey) {
stopPropagation();
event.stopImmediatePropagation();
event.stopPropagation();
const href = element.getAttribute('href');
const validUrl = _.getValidUrl(href);
@ -750,10 +725,22 @@ export default class UI extends Module<UINodes> {
return;
}
this.processBottomZoneClick(event);
}
/**
* Check if user clicks on the Editor's bottom zone:
* - set caret to the last block
* - or add new empty block
*
* @param event - click event
*/
private processBottomZoneClick(event: MouseEvent): void {
const lastBlock = this.Editor.BlockManager.getBlockByIndex(-1);
const lastBlockBottomCoord = $.offset(lastBlock.holder).bottom;
const clickedCoord = event.pageY;
const { BlockSelection } = this.Editor;
const isClickedBottom = event.target instanceof Element &&
event.target.isEqualNode(this.nodes.redactor) &&
/**
@ -767,7 +754,8 @@ export default class UI extends Module<UINodes> {
lastBlockBottomCoord < clickedCoord;
if (isClickedBottom) {
stopPropagation();
event.stopImmediatePropagation();
event.stopPropagation();
const { BlockManager, Caret, Toolbar } = this.Editor;

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

@ -6,53 +6,66 @@ import CodeXTooltips from 'codex-tooltip';
import type { TooltipOptions, TooltipContent } from 'codex-tooltip/types';
/**
* Tooltip
* Tooltips lib: CodeX Tooltips
*
* Decorates any tooltip module like adapter
* @see https://github.com/codex-team/codex.tooltips
*/
export default class Tooltip {
/**
* Tooltips lib: CodeX Tooltips
*
* @see https://github.com/codex-team/codex.tooltips
*/
private lib: CodeXTooltips = new CodeXTooltips();
let lib: null | CodeXTooltips = null;
/**
* Release the library
*/
public destroy(): void {
this.lib.destroy();
/**
* If library is needed, but it is not initialized yet, this function will initialize it
*
* For example, if editor was destroyed and then initialized again
*/
function prepare(): void {
if (lib) {
return;
}
/**
* Shows tooltip on element with passed HTML content
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
public show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.lib.show(element, content, options);
}
/**
* Hides tooltip
*
* @param skipHidingDelay pass true to immediately hide the tooltip
*/
public hide(skipHidingDelay = false): void {
this.lib.hide(skipHidingDelay);
}
/**
* Binds 'mouseenter' and 'mouseleave' events that shows/hides the Tooltip
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
public onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.lib.onHover(element, content, options);
}
lib = new CodeXTooltips();
}
/**
* Shows tooltip on element with passed HTML content
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
export function show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
prepare();
lib?.show(element, content, options);
}
/**
* Hides tooltip
*
* @param skipHidingDelay pass true to immediately hide the tooltip
*/
export function hide(skipHidingDelay = false): void {
prepare();
lib?.hide(skipHidingDelay);
}
/**
* Binds 'mouseenter' and 'mouseleave' events that shows/hides the Tooltip
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
export function onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
prepare();
lib?.onHover(element, content, options);
}
/**
* Release the library
*/
export function destroy(): void {
lib?.destroy();
lib = null;
}

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

@ -0,0 +1,16 @@
import { BlockMutationEvent, BlockMutationType } from '../../../../types';
/**
* Simplified version of the BlockMutationEvent with optional fields that could be used in tests
*/
export default interface PartialBlockMutationEvent {
/**
* Event type
*/
type?: BlockMutationType,
/**
* Details with partial properties
*/
detail?: Partial<BlockMutationEvent['detail']>
}

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

@ -0,0 +1,59 @@
/* global chai */
// because this file is imported from cypress/support/e2e.js
// that means all other spec files will have this assertion plugin
// available to them because the supportFile is bundled and served
// prior to any spec files loading
import PartialBlockMutationEvent from '../fixtures/types/PartialBlockMutationEvent';
/**
* Chai plugin for checking if passed onChange method is called with an array of passed events
*
* @param _chai - Chai instance
*/
const beCalledWithBatchedEvents = (_chai): void => {
/**
* Check if passed onChange method is called with an array of passed events
*
* @param expectedEvents - batched events to check
*/
function assertToBeCalledWithBatchedEvents(expectedEvents: PartialBlockMutationEvent[]): void {
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
const EditorJSApiMock = Cypress.sinon.match.any;
const $onChange = this._obj;
this.assert(
$onChange.calledOnce,
'expected #{this} to be called once',
'expected #{this} to not be called once'
);
this.assert(
$onChange.calledWithMatch(
EditorJSApiMock,
Cypress.sinon.match((events: PartialBlockMutationEvent[]) => {
expect(events).to.be.an('array');
return events.every((event, index) => {
const eventToCheck = expectedEvents[index];
return expect(event).to.containSubset(eventToCheck);
});
})
),
'expected #{this} to be called with #{exp}, but it was called with #{act}',
'expected #{this} to not be called with #{exp}, but it was called with #{act} ',
expectedEvents
);
}
_chai.Assertion.addMethod('calledWithBatchedEvents', assertToBeCalledWithBatchedEvents);
};
/**
* registers our assertion function "beCalledWithBatchedEvents" with Chai
*/
chai.use(beCalledWithBatchedEvents);

View file

@ -1,9 +1,9 @@
// in cypress/support/index.d.ts
// load type definitions that come with Cypress module
/// <reference types="cypress" />
import type { EditorConfig, OutputData } from './../../../types/index';
import type EditorJS from '../../../types/index'
import PartialBlockMutationEvent from '../fixtures/types/PartialBlockMutationEvent';
declare global {
namespace Cypress {
@ -65,6 +65,22 @@ declare global {
interface ApplicationWindow {
EditorJS: typeof EditorJS
}
/**
* Extends Cypress assertion Chainer interface with the new assertion methods
*/
interface Chainer<Subject> {
/**
* Custom Chai assertion that checks if given onChange method is called with an array of passed events
*
* @example
* ```
* cy.get('@onChange').should('be.calledWithBatchedEvents', [{ type: 'block-added', detail: { index: 0 }}])
* expect(onChange).to.be.calledWithBatchedEvents([{ type: 'block-added', detail: { index: 0 }}])
* ```
*/
(chainer: 'be.calledWithBatchedEvents', expectedEvents: PartialBlockMutationEvent[]): Chainable<Subject>;
}
}
/**
@ -76,6 +92,17 @@ declare global {
* "containSubset" object properties matcher
*/
containSubset(subset: any): Assertion;
/**
* Custom Chai assertion that checks if given onChange method is called with an array of passed events
*
* @example
* ```
* cy.get('@onChange').should('be.calledWithBatchedEvents', [{ type: 'block-added', detail: { index: 0 }}])
* expect(onChange).to.be.calledWithBatchedEvents([{ type: 'block-added', detail: { index: 0 }}])
* ```
*/
calledWithBatchedEvents(expectedEvents: PartialBlockMutationEvent[]): Assertion;
}
}
}

View file

@ -7,12 +7,20 @@
*/
import '@cypress/code-coverage/support';
import installLogsCollector from 'cypress-terminal-report/src/installLogsCollector';
installLogsCollector();
/**
* File with the helpful commands
*/
import './commands';
/**
* File with custom assertions
*/
import './e2e';
import chaiSubset from 'chai-subset';
/**

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,21 +108,23 @@ 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',
};
editor.blocks.update(idToUpdate, newBlockData);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.invoke('text')
.then(blockText => {
expect(blockText).to.be.eq(firstBlock.data.text);
editor.blocks.update(idToUpdate, newBlockData)
.catch(error => {
expect(error.message).to.be.eq(`Block with id "${idToUpdate}" not found`);
})
.finally(() => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.invoke('text')
.then(blockText => {
expect(blockText).to.be.eq(firstBlock.data.text);
});
});
});
});
@ -138,9 +137,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 +154,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 +246,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 +295,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 +334,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,179 @@
import ToolMock from '../../fixtures/tools/ToolMock';
import type EditorJS from '../../../../types/index';
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');
}
});
});
it('should insert default empty block when [] passed as data.blocks', function () {
cy.createEditor({
data: {
blocks: [],
},
})
.as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.should('have.length', 1);
});
it('should insert default empty block when [] passed via blocks.render() API', function () {
cy.createEditor({})
.as('editorInstance');
cy.get<EditorJS>('@editorInstance')
.then((editor) => {
editor.blocks.render({
blocks: [],
});
});
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.should('have.length', 1);
});
});

View file

@ -5,33 +5,13 @@ 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';
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
const EditorJSApiMock = Cypress.sinon.match.any;
/**
* Check if passed onChange method is called with an array of passed events
*
* @param $onChange - editor onChange spy
* @param expectedEvents - batched events to check
*/
function beCalledWithBatchedEvents($onChange, expectedEvents): void {
expect($onChange).to.be.calledOnce;
expect($onChange).to.be.calledWithMatch(
EditorJSApiMock,
Cypress.sinon.match((events) => {
return events.every((event, index) => {
const eventToCheck = expectedEvents[index];
return expect(event).to.containSubset(eventToCheck);
});
})
);
}
/**
* @todo Add checks that correct block API object is passed to onChange
* @todo Add cases for native inputs changes
@ -104,22 +84,20 @@ describe('onChange callback', () => {
.type('change')
.type('{enter}');
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockChangedMutationType,
detail: {
index: 0,
},
cy.get('@onChange').should('be.calledWithBatchedEvents', [
{
type: BlockChangedMutationType,
detail: {
index: 0,
},
{
type: BlockAddedMutationType,
detail: {
index: 1,
},
},
{
type: BlockAddedMutationType,
detail: {
index: 1,
},
]);
});
},
]);
});
it('should filter out similar events on batching', () => {
@ -132,7 +110,6 @@ describe('onChange callback', () => {
},
]);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
@ -243,37 +220,35 @@ describe('onChange callback', () => {
.get('div.ce-popover-item[data-item-name=delimiter]')
.click();
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockRemovedMutationType,
detail: {
index: 0,
target: {
name: 'paragraph',
},
cy.get('@onChange').should('be.calledWithBatchedEvents', [
{
type: BlockRemovedMutationType,
detail: {
index: 0,
target: {
name: 'paragraph',
},
},
{
type: BlockAddedMutationType,
detail: {
index: 0,
target: {
name: 'delimiter',
},
},
{
type: BlockAddedMutationType,
detail: {
index: 0,
target: {
name: 'delimiter',
},
},
{
type: BlockAddedMutationType,
detail: {
index: 1,
target: {
name: 'paragraph',
},
},
{
type: BlockAddedMutationType,
detail: {
index: 1,
target: {
name: 'paragraph',
},
},
]);
});
},
]);
});
it('should be fired on block replacement for both of blocks', () => {
@ -291,28 +266,26 @@ describe('onChange callback', () => {
.get('div.ce-popover-item[data-item-name=header]')
.click();
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockRemovedMutationType,
detail: {
index: 0,
target: {
name: 'paragraph',
},
cy.get('@onChange').should('be.calledWithBatchedEvents', [
{
type: BlockRemovedMutationType,
detail: {
index: 0,
target: {
name: 'paragraph',
},
},
{
type: BlockAddedMutationType,
detail: {
index: 0,
target: {
name: 'header',
},
},
{
type: BlockAddedMutationType,
detail: {
index: 0,
target: {
name: 'header',
},
},
]);
});
},
]);
});
it('should be fired on tune modifying', () => {
@ -375,28 +348,26 @@ describe('onChange callback', () => {
.get('div[data-item-name=delete]')
.click();
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
/**
* "block-removed" fired since we have deleted a block
*/
{
type: BlockRemovedMutationType,
detail: {
index: 0,
},
cy.get('@onChange').should('be.calledWithBatchedEvents', [
/**
* "block-removed" fired since we have deleted a block
*/
{
type: BlockRemovedMutationType,
detail: {
index: 0,
},
/**
* "block-added" fired since we have deleted the last block, so the new one is created
*/
{
type: BlockAddedMutationType,
detail: {
index: 0,
},
},
/**
* "block-added" fired since we have deleted the last block, so the new one is created
*/
{
type: BlockAddedMutationType,
detail: {
index: 0,
},
]);
});
},
]);
});
it('should be fired when block is moved', () => {
@ -483,7 +454,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 +532,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 +539,114 @@ 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('be.calledWithBatchedEvents', [
{
type: BlockRemovedMutationType,
},
{
type: BlockRemovedMutationType,
},
{
type: BlockAddedMutationType,
},
]);
});
it('should not be called on blocks.render() on non-empty editor', () => {
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('have.callCount', 0);
});
it('should be called on blocks.update() with "block-changed" event', () => {
const block = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block mock.',
},
};
const config = {
data: {
blocks: [
block,
],
},
onChange: (api, event): void => {
console.log('something changed', event);
},
};
cy.spy(config, 'onChange').as('onChange');
cy.createEditor(config)
.then((editor) => {
editor.blocks.update(block.id, {
text: 'Updated text',
});
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockChangedMutationType,
detail: {
index: 0,
target: {
id: block.id,
},
},
}));
});
});
});

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()

17
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';
@ -9,7 +9,7 @@ export interface Blocks {
/**
* Remove all blocks from Editor zone
*/
clear(): void;
clear(): Promise<void>;
/**
* Render passed data
@ -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.
@ -127,9 +134,9 @@ export interface Blocks {
* Updates block data by id
*
* @param id - id of the block to update
* @param data - the new data
* @param data - the new data. Can be partial.
*/
update(id: string, data: BlockToolData): void;
update(id: string, data: Partial<BlockToolData>): Promise<BlockAPI>;
/**
* Converts block to another type. Both blocks should provide the conversionConfig.

4
types/index.d.ts vendored
View file

@ -31,7 +31,7 @@ import {
} from './api';
import { OutputData } from './data-formats';
import { BlockMutationEventMap } from './events/block';
import { BlockMutationEvent, BlockMutationEventMap, BlockMutationType } from './events/block';
import { BlockAddedMutationType, BlockAddedEvent } from './events/block/BlockAdded';
import { BlockChangedMutationType, BlockChangedEvent } from './events/block/BlockChanged';
import { BlockMovedMutationType, BlockMovedEvent } from './events/block/BlockMoved';
@ -85,6 +85,8 @@ export { OutputData, OutputBlockData} from './data-formats/output-data';
export { BlockId } from './data-formats/block-id';
export { BlockAPI } from './api'
export {
BlockMutationType,
BlockMutationEvent,
BlockMutationEventMap,
BlockAddedMutationType,
BlockAddedEvent,

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"