editor.js/src/components/modules/blockManager.ts
Peter Savchenko 3272efc3f7
chore(linting): eslint updated, code linted (#2174)
* update eslint + autofix

* a bunch of eslint fixes

* some spelling & eslint fixes

* fix some eslint errors and spells

* Update __module.ts

* a bunch of eslint fixes in tests

* Update cypress.yml

* Update cypress.yml

* fix cypress docker image name

* fixes for tests

* more tests fixed

* rm rule ignore

* rm another ignored rule

* Update .eslintrc
2022-11-25 21:56:50 +04:00

869 lines
21 KiB
TypeScript

/**
* @class BlockManager
* @classdesc Manage editor`s blocks storage and appearance
* @module BlockManager
* @version 2.0.0
*/
import Block, { BlockToolAPI } from '../block';
import Module from '../__module';
import $ from '../dom';
import * as _ from '../utils';
import Blocks from '../blocks';
import { BlockToolData, PasteEvent } from '../../../types';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import BlockAPI from '../block/api';
import { BlockMutationType } from '../../../types/events/block/mutation-type';
/**
* @typedef {BlockManager} BlockManager
* @property {number} currentBlockIndex - Index of current working block
* @property {Proxy} _blocks - Proxy for Blocks instance {@link Blocks}
*/
export default class BlockManager extends Module {
/**
* Returns current Block index
*
* @returns {number}
*/
public get currentBlockIndex(): number {
return this._currentBlockIndex;
}
/**
* Set current Block index and fire Block lifecycle callbacks
*
* @param {number} newIndex - index of Block to set as current
*/
public set currentBlockIndex(newIndex: number) {
if (this._blocks[this._currentBlockIndex]) {
this._blocks[this._currentBlockIndex].willUnselect();
}
if (this._blocks[newIndex]) {
this._blocks[newIndex].willSelect();
}
this._currentBlockIndex = newIndex;
}
/**
* returns first Block
*
* @returns {Block}
*/
public get firstBlock(): Block {
return this._blocks[0];
}
/**
* returns last Block
*
* @returns {Block}
*/
public get lastBlock(): Block {
return this._blocks[this._blocks.length - 1];
}
/**
* Get current Block instance
*
* @returns {Block}
*/
public get currentBlock(): Block {
return this._blocks[this.currentBlockIndex];
}
/**
* Set passed Block as a current
*
* @param block - block to set as a current
*/
public set currentBlock(block: Block) {
this.currentBlockIndex = this.getBlockIndex(block);
}
/**
* Returns next Block instance
*
* @returns {Block|null}
*/
public get nextBlock(): Block {
const isLastBlock = this.currentBlockIndex === (this._blocks.length - 1);
if (isLastBlock) {
return null;
}
return this._blocks[this.currentBlockIndex + 1];
}
/**
* Return first Block with inputs after current Block
*
* @returns {Block | undefined}
*/
public get nextContentfulBlock(): Block {
const nextBlocks = this.blocks.slice(this.currentBlockIndex + 1);
return nextBlocks.find((block) => !!block.inputs.length);
}
/**
* Return first Block with inputs before current Block
*
* @returns {Block | undefined}
*/
public get previousContentfulBlock(): Block {
const previousBlocks = this.blocks.slice(0, this.currentBlockIndex).reverse();
return previousBlocks.find((block) => !!block.inputs.length);
}
/**
* Returns previous Block instance
*
* @returns {Block|null}
*/
public get previousBlock(): Block {
const isFirstBlock = this.currentBlockIndex === 0;
if (isFirstBlock) {
return null;
}
return this._blocks[this.currentBlockIndex - 1];
}
/**
* Get array of Block instances
*
* @returns {Block[]} {@link Blocks#array}
*/
public get blocks(): Block[] {
return this._blocks.array;
}
/**
* Check if each Block is empty
*
* @returns {boolean}
*/
public get isEditorEmpty(): boolean {
return this.blocks.every((block) => block.isEmpty);
}
/**
* Index of current working block
*
* @type {number}
*/
private _currentBlockIndex = -1;
/**
* Proxy for Blocks instance {@link Blocks}
*
* @type {Proxy}
* @private
*/
private _blocks: Blocks = null;
/**
* Should be called after Editor.UI preparation
* Define this._blocks property
*/
public prepare(): void {
const blocks = new Blocks(this.Editor.UI.nodes.redactor);
/**
* We need to use Proxy to overload set/get [] operator.
* So we can use array-like syntax to access blocks
*
* @example
* this._blocks[0] = new Block(...);
*
* block = this._blocks[0];
* @todo proxy the enumerate method
* @type {Proxy}
* @private
*/
this._blocks = new Proxy(blocks, {
set: Blocks.set,
get: Blocks.get,
});
/** Copy event */
this.listeners.on(
document,
'copy',
(e: ClipboardEvent) => this.Editor.BlockEvents.handleCommandC(e)
);
}
/**
* Toggle read-only state
*
* If readOnly is true:
* - Unbind event handlers from created Blocks
*
* if readOnly is false:
* - Bind event handlers to all existing Blocks
*
* @param {boolean} readOnlyEnabled - "read only" state
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) {
this.enableModuleBindings();
} else {
this.disableModuleBindings();
}
}
/**
* Creates Block instance by tool name
*
* @param {object} options - block creation options
* @param {string} options.tool - tools passed in editor config {@link EditorConfig#tools}
* @param {string} [options.id] - unique id for this block
* @param {BlockToolData} [options.data] - constructor params
* @returns {Block}
*/
public composeBlock({
tool: name,
data = {},
id = undefined,
tunes: tunesData = {},
}: {tool: string; id?: string; data?: BlockToolData; tunes?: {[name: string]: BlockTuneData}}): Block {
const readOnly = this.Editor.ReadOnly.isEnabled;
const tool = this.Editor.Tools.blockTools.get(name);
const block = new Block({
id,
data,
tool,
api: this.Editor.API,
readOnly,
tunesData,
});
if (!readOnly) {
this.bindBlockEvents(block);
}
return block;
}
/**
* Insert new block into _blocks
*
* @param {object} options - insert options
* @param {string} [options.id] - block's unique id
* @param {string} [options.tool] - plugin name, by default method inserts the default block type
* @param {object} [options.data] - plugin data
* @param {number} [options.index] - index where to insert new Block
* @param {boolean} [options.needToFocus] - flag shows if needed to update current Block index
* @param {boolean} [options.replace] - flag shows if block by passed index should be replaced with inserted one
* @returns {Block}
*/
public insert({
id = undefined,
tool = this.config.defaultBlock,
data = {},
index,
needToFocus = true,
replace = false,
tunes = {},
}: {
id?: string;
tool?: string;
data?: BlockToolData;
index?: number;
needToFocus?: boolean;
replace?: boolean;
tunes?: {[name: string]: BlockTuneData};
} = {}): Block {
let newIndex = index;
if (newIndex === undefined) {
newIndex = this.currentBlockIndex + (replace ? 0 : 1);
}
const block = this.composeBlock({
id,
tool,
data,
tunes,
});
/**
* In case of block replacing (Converting OR from Toolbox or Shortcut on empty block OR on-paste to empty block)
* we need to dispatch the 'block-removing' event for the replacing block
*/
if (replace) {
this.blockDidMutated(BlockMutationType.Removed, this.getBlockByIndex(newIndex), {
index: newIndex,
});
}
this._blocks.insert(newIndex, block, replace);
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(BlockMutationType.Added, block, {
index: newIndex,
});
if (needToFocus) {
this.currentBlockIndex = newIndex;
} else if (newIndex <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
return block;
}
/**
* Replace current working block
*
* @param {object} options - replace options
* @param {string} options.tool — plugin name
* @param {BlockToolData} options.data — plugin data
* @returns {Block}
*/
public replace({
tool = this.config.defaultBlock,
data = {},
}): Block {
return this.insert({
tool,
data,
index: this.currentBlockIndex,
replace: true,
});
}
/**
* Insert pasted content. Call onPaste callback after insert.
*
* @param {string} toolName - name of Tool to insert
* @param {PasteEvent} pasteEvent - pasted data
* @param {boolean} replace - should replace current block
*/
public paste(
toolName: string,
pasteEvent: PasteEvent,
replace = false
): Block {
const block = this.insert({
tool: toolName,
replace,
});
try {
block.call(BlockToolAPI.ON_PASTE, pasteEvent);
} catch (e) {
_.log(`${toolName}: onPaste callback call is failed`, 'error', e);
}
return block;
}
/**
* Insert new default block at passed index
*
* @param {number} index - index where Block should be inserted
* @param {boolean} needToFocus - if true, updates current Block index
*
* TODO: Remove method and use insert() with index instead (?)
* @returns {Block} inserted Block
*/
public insertDefaultBlockAtIndex(index: number, needToFocus = false): Block {
const block = this.composeBlock({ tool: this.config.defaultBlock });
this._blocks[index] = block;
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(BlockMutationType.Added, block, {
index,
});
if (needToFocus) {
this.currentBlockIndex = index;
} else if (index <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
return block;
}
/**
* Always inserts at the end
*
* @returns {Block}
*/
public insertAtEnd(): Block {
/**
* Define new value for current block index
*/
this.currentBlockIndex = this.blocks.length - 1;
/**
* Insert the default typed block
*/
return this.insert();
}
/**
* Merge two blocks
*
* @param {Block} targetBlock - previous block will be append to this block
* @param {Block} blockToMerge - block that will be merged with target block
* @returns {Promise} - the sequence that can be continued
*/
public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise<void> {
const blockToMergeIndex = this._blocks.indexOf(blockToMerge);
if (blockToMerge.isEmpty) {
return;
}
const blockToMergeData = await blockToMerge.data;
if (!_.isEmpty(blockToMergeData)) {
await targetBlock.mergeWith(blockToMergeData);
}
this.removeBlock(blockToMergeIndex);
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
}
/**
* Remove block with passed index or remove last
*
* @param {number|null} index - index of Block to remove
* @throws {Error} if Block to remove is not found
*/
public removeBlock(index = this.currentBlockIndex): void {
/**
* 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');
}
const blockToRemove = this._blocks[index];
blockToRemove.destroy();
this._blocks.remove(index);
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockMutationType.Removed, blockToRemove, {
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;
this.insert();
} else if (index === 0) {
this.currentBlockIndex = 0;
}
}
/**
* Remove only selected Blocks
* and returns first Block index where started removing...
*
* @returns {number|undefined}
*/
public removeSelectedBlocks(): number | undefined {
let firstSelectedBlockIndex;
/**
* Remove selected Blocks from the end
*/
for (let index = this.blocks.length - 1; index >= 0; index--) {
if (!this.blocks[index].selected) {
continue;
}
this.removeBlock(index);
firstSelectedBlockIndex = index;
}
return firstSelectedBlockIndex;
}
/**
* Attention!
* After removing insert the new default typed Block and focus on it
* Removes all blocks
*/
public removeAllBlocks(): void {
for (let index = this.blocks.length - 1; index >= 0; index--) {
this._blocks.remove(index);
}
this.currentBlockIndex = -1;
this.insert();
this.currentBlock.firstInput.focus();
}
/**
* Split current Block
* 1. Extract content from Caret position to the Block`s end
* 2. Insert a new Block below current one with extracted content
*
* @returns {Block}
*/
public split(): Block {
const extractedFragment = this.Editor.Caret.extractFragmentFromCaretPosition();
const wrapper = $.make('div');
wrapper.appendChild(extractedFragment as DocumentFragment);
/**
* @todo make object in accordance with Tool
*/
const data = {
text: $.isEmpty(wrapper) ? '' : wrapper.innerHTML,
};
/**
* Renew current Block
*
* @type {Block}
*/
return this.insert({ data });
}
/**
* Returns Block by passed index
*
* @param {number} index - index to get. -1 to get last
* @returns {Block}
*/
public getBlockByIndex(index): Block {
if (index === -1) {
index = this._blocks.length - 1;
}
return this._blocks[index];
}
/**
* Returns an index for passed Block
*
* @param block - block to find index
*/
public getBlockIndex(block: Block): number {
return this._blocks.indexOf(block);
}
/**
* Returns the Block by passed id
*
* @param id - id of block to get
* @returns {Block}
*/
public getBlockById(id): Block | undefined {
return this._blocks.array.find(block => block.id === id);
}
/**
* Get Block instance by html element
*
* @param {Node} element - html element to get Block by
*/
public getBlock(element: HTMLElement): Block {
if (!$.isElement(element) as boolean) {
element = element.parentNode as HTMLElement;
}
const nodes = this._blocks.nodes,
firstLevelBlock = element.closest(`.${Block.CSS.wrapper}`),
index = nodes.indexOf(firstLevelBlock as HTMLElement);
if (index >= 0) {
return this._blocks[index];
}
}
/**
* Remove selection from all Blocks then highlight only Current Block
*/
public highlightCurrentNode(): void {
/**
* Remove previous selected Block's state
*/
this.clearFocused();
/**
* Mark current Block as selected
*
* @type {boolean}
*/
this.currentBlock.focused = true;
}
/**
* Remove selection from all Blocks
*/
public clearFocused(): void {
this.blocks.forEach((block) => {
block.focused = false;
});
}
/**
* 1) Find first-level Block from passed child Node
* 2) Mark it as current
*
* @param {Node} childNode - look ahead from this node.
* @returns {Block | undefined} can return undefined in case when the passed child note is not a part of the current editor instance
*/
public setCurrentBlockByChildNode(childNode: Node): Block | undefined {
/**
* If node is Text TextNode
*/
if (!$.isElement(childNode)) {
childNode = childNode.parentNode;
}
const parentFirstLevelBlock = (childNode as HTMLElement).closest(`.${Block.CSS.wrapper}`);
if (!parentFirstLevelBlock) {
return;
}
/**
* Support multiple Editor.js instances,
* by checking whether the found block belongs to the current instance
*
* @see {@link Ui#documentTouched}
*/
const editorWrapper = parentFirstLevelBlock.closest(`.${this.Editor.UI.CSS.editorWrapper}`);
const isBlockBelongsToCurrentInstance = editorWrapper?.isEqualNode(this.Editor.UI.nodes.wrapper);
if (!isBlockBelongsToCurrentInstance) {
return;
}
/**
* Update current Block's index
*
* @type {number}
*/
this.currentBlockIndex = this._blocks.nodes.indexOf(parentFirstLevelBlock as HTMLElement);
/**
* Update current block active input
*/
this.currentBlock.updateCurrentInput();
return this.currentBlock;
}
/**
* Return block which contents passed node
*
* @param {Node} childNode - node to get Block by
* @returns {Block}
*/
public getBlockByChildNode(childNode: Node): Block {
/**
* If node is Text TextNode
*/
if (!$.isElement(childNode)) {
childNode = childNode.parentNode;
}
const firstLevelBlock = (childNode as HTMLElement).closest(`.${Block.CSS.wrapper}`);
return this.blocks.find((block) => block.holder === firstLevelBlock);
}
/**
* Swap Blocks Position
*
* @param {number} fromIndex - index of first block
* @param {number} toIndex - index of second block
* @deprecated — use 'move' instead
*/
public swap(fromIndex, toIndex): void {
/** Move up current Block */
this._blocks.swap(fromIndex, toIndex);
/** Now actual block moved up so that current block index decreased */
this.currentBlockIndex = toIndex;
}
/**
* Move a block to a new index
*
* @param {number} toIndex - index where to move Block
* @param {number} fromIndex - index of Block to move
*/
public move(toIndex, fromIndex = this.currentBlockIndex): void {
// make sure indexes are valid and within a valid range
if (isNaN(toIndex) || isNaN(fromIndex)) {
_.log(`Warning during 'move' call: incorrect indices provided.`, 'warn');
return;
}
if (!this.validateIndex(toIndex) || !this.validateIndex(fromIndex)) {
_.log(`Warning during 'move' call: indices cannot be lower than 0 or greater than the amount of blocks.`, 'warn');
return;
}
/** Move up current Block */
this._blocks.move(toIndex, fromIndex);
/** Now actual block moved so that current block index changed */
this.currentBlockIndex = toIndex;
/**
* Force call of didMutated event on Block movement
*/
this.blockDidMutated(BlockMutationType.Moved, this.currentBlock, {
fromIndex,
toIndex,
});
}
/**
* Sets current Block Index -1 which means unknown
* and clear highlights
*/
public dropPointer(): void {
this.currentBlockIndex = -1;
this.clearFocused();
}
/**
* Clears Editor
*
* @param {boolean} needToAddDefaultBlock - 1) in internal calls (for example, in api.blocks.render)
* 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();
this.dropPointer();
if (needToAddDefaultBlock) {
this.insert();
}
/**
* Add empty modifier
*/
this.Editor.UI.checkEmptiness();
}
/**
* Cleans up all the block tools' resources
* This is called when editor is destroyed
*/
public async destroy(): Promise<void> {
await Promise.all(this.blocks.map((block) => {
return block.destroy();
}));
}
/**
* Bind Block events
*
* @param {Block} block - Block to which event should be bound
*/
private bindBlockEvents(block: Block): void {
const { BlockEvents } = this.Editor;
this.readOnlyMutableListeners.on(block.holder, 'keydown', (event: KeyboardEvent) => {
BlockEvents.keydown(event);
});
this.readOnlyMutableListeners.on(block.holder, 'keyup', (event: KeyboardEvent) => {
BlockEvents.keyup(event);
});
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: DragEvent) => {
BlockEvents.dragOver(event);
});
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: DragEvent) => {
BlockEvents.dragLeave(event);
});
block.on('didMutated', (affectedBlock: Block) => {
return this.blockDidMutated(BlockMutationType.Changed, affectedBlock, {
index: this.getBlockIndex(affectedBlock),
});
});
}
/**
* Disable mutable handlers and bindings
*/
private disableModuleBindings(): void {
this.readOnlyMutableListeners.clearAll();
}
/**
* Enables all module handlers and bindings for all Blocks
*/
private enableModuleBindings(): void {
/** Cut event */
this.readOnlyMutableListeners.on(
document,
'cut',
(e: ClipboardEvent) => this.Editor.BlockEvents.handleCommandX(e)
);
this.blocks.forEach((block: Block) => {
this.bindBlockEvents(block);
});
}
/**
* Validates that the given index is not lower than 0 or higher than the amount of blocks
*
* @param {number} index - index of blocks array to validate
* @returns {boolean}
*/
private validateIndex(index: number): boolean {
return !(index < 0 || index >= this._blocks.length);
}
/**
* Block mutation callback
*
* @param mutationType - what happened with block
* @param block - mutated block
* @param details - additional data to pass with change event
*/
private blockDidMutated(mutationType: BlockMutationType, block: Block, details: Record<string, unknown> = {}): Block {
const event = new CustomEvent(mutationType, {
detail: {
target: new BlockAPI(block),
...details,
},
});
this.Editor.ModificationsObserver.onChange(event);
return block;
}
}