Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 17 commits into from
Aug 8, 2023
Prev Previous commit
Next Next commit
stub block added, tests added
  • Loading branch information
neSpecc committed Aug 4, 2023
commit 4c22eedb08b3b58d6dcb0e7c223f77dc36a1a6be
3 changes: 3 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
- `Improvement` - Performance optimizations: initialization speed increased, `blocks.render()` API method optimized. Big documents will be displayed faster.
- `Improvement` - "Editor saving" log removed
- `Improvement` - "I'm ready" log removed
- `Improvement` - The stub-block style simplified.
- `Improvement` - If some Block's tool will throw an error during construction, we will show Stub block instead of skipping it during render
- `Improvement` - Call of `blocks.clear()` now will trigger onChange will "block-removed" event for all removed blocks.

### 2.27.2

Expand Down
7 changes: 5 additions & 2 deletions src/components/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ export default class Core {
await this.start();
await this.render();

if ((this.configuration as EditorConfig).autofocus) {
const { BlockManager, Caret } = this.moduleInstances;
const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances;

UI.checkEmptiness();
ModificationsObserver.enable();

if ((this.configuration as EditorConfig).autofocus) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
BlockManager.highlightCurrentNode();
}
Expand Down
8 changes: 6 additions & 2 deletions src/components/modules/api/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,12 @@ export default class BlocksAPI extends Module {
*
* @param {OutputData} data — Saved Editor data
*/
public render(data: OutputData): Promise<void> {
this.Editor.BlockManager.clear();
public async render(data: OutputData): Promise<void> {
if (data === undefined || data.blocks === undefined) {
throw new Error('Incorrect data passed to the render() method');
}

await this.Editor.BlockManager.clear();

return this.Editor.Renderer.render(data.blocks);
}
Expand Down
78 changes: 48 additions & 30 deletions src/components/modules/blockManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -445,40 +446,48 @@ export default class BlockManager extends Module {
* Remove passed Block
*
* @param block - Block to remove
* @param addLastBlock - if true, adds new default block at the end. @todo remove this logic and use event-bus instead
*/
public removeBlock(block: Block): void {
const index = this._blocks.indexOf(block);
public removeBlock(block: Block, addLastBlock = true): Promise<void> {
return new Promise((resolve) => {
const index = this._blocks.indexOf(block);

/**
* If index is not passed and there is no block selected, show a warning
*/
if (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove');
}
/**
* If index is not passed and there is no block selected, show a warning
*/
if (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove');
}

block.destroy();
this._blocks.remove(index);
block.destroy();
this._blocks.remove(index);

/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, block, {
index,
});
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, block, {
index,
});

if (this.currentBlockIndex >= index) {
this.currentBlockIndex--;
}
if (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;
}
/**
* 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();
});
}

/**
Expand Down Expand Up @@ -816,8 +825,17 @@ export default class BlockManager extends Module {
* we don't need to add an empty default block
* 2) in api.blocks.clear we should add empty block
*/
public clear(needToAddDefaultBlock = false): void {
this._blocks.removeAll();
public async clear(needToAddDefaultBlock = false): Promise<void> {
const queue = new PromiseQueue();

this.blocks.forEach((block) => {
queue.add(async () => {
await this.removeBlock(block, false);
});
});

await queue.completed;

this.dropPointer();

if (needToAddDefaultBlock) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/modules/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ export default class Paste extends Module {
const { BlockManager, Toolbar } = this.Editor;

/**
* When someone passing into some block, its more stable to set current block by event target, instead of relying on current block set before
* 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);

Expand Down
132 changes: 62 additions & 70 deletions src/components/modules/renderer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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';

/**
* Module that responsible for rendering Blocks on editor initialization
Expand All @@ -14,102 +16,92 @@ export default class Renderer extends Module {
*/
public async render(blocksData: OutputBlockData[]): Promise<void> {
return new Promise((resolve) => {
/**
* Disable onChange callback on render to not to spam those events
*/
this.Editor.ModificationsObserver.disable();
const { Tools, BlockManager } = this.Editor;

/**
* Create Blocks instances
*/
const blocks = blocksData.map(({ type: tool, data, tunes, id }) => {
/**
* @todo handle plugin error
* @todo handle stub case
*/
if (Tools.available.has(tool) === false) {
_.logLabeled(`Tool «${tool}» is not found. Check 'tools' property at the Editor.js config.`, 'warn');

data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
}

let block: Block;

try {
block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'error', {
data,
error,
});

/**
* If tool throws an error during render, we should render stub instead of it
*/
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;

block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
}

return this.Editor.BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
return block;
});

/**
* Insert batch of Blocks
*/
this.Editor.BlockManager.insertMany(blocks);
BlockManager.insertMany(blocks);

/**
* Do some post-render stuff.
* Wait till browser will render inserted Blocks and resolve a promise
*/
window.requestIdleCallback(() => {
this.Editor.UI.checkEmptiness();
/**
* Enable onChange callback back
*/
this.Editor.ModificationsObserver.enable();
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>}
* @deprecated
* @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);
}
} else {
/** If Tool is unavailable, create stub Block for it */
const stubData = {
savedData: {
id,
type: tool,
data,
},
title: tool,
};
let title = tool;

if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
const toolboxTitle = toolboxSettings[0]?.title;
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;

stubData.title = toolboxTitle || stubData.title;
if (toolboxSettings !== undefined && toolboxSettings[0].title !== undefined) {
title = toolboxSettings[0].title;
}
}

const stub = BlockManager.insert({
return {
savedData: {
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');
}
type: tool,
data,
},
title,
};
}
}
2 changes: 2 additions & 0 deletions src/components/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ 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[],
Expand Down
28 changes: 28 additions & 0 deletions src/components/utils/promise-queue.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
Loading