mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 15:15:47 +01:00
* pass config to the conversionConfig.import method - Now `convertStringToBlockData` method passes target tool config the import method - Fixed types in convesion config file (somehow imprort could return function that returns string, but import should return method that would return ToolData) this caused just type error that never been reached because types were actually ignored - Added test that checks, that import method actualy gets passed config * update changelog * eslint fix * updated test description * jsdoc improved * typos in changelog
1017 lines
27 KiB
TypeScript
1017 lines
27 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 type { BlockToolData, PasteEvent } from '../../../types';
|
|
import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
|
import BlockAPI from '../block/api';
|
|
import type { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block';
|
|
import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
|
|
import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
|
|
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
|
|
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
|
|
import { BlockChanged } from '../events';
|
|
import { clean, sanitizeBlocks } from '../utils/sanitizer';
|
|
import { convertStringToBlockData, isBlockConvertable } from '../utils/blocks';
|
|
import PromiseQueue from '../utils/promise-queue';
|
|
|
|
/**
|
|
* @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) {
|
|
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 | undefined {
|
|
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 | null {
|
|
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 | null {
|
|
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,
|
|
}, this.eventsDispatcher);
|
|
|
|
if (!readOnly) {
|
|
window.requestIdleCallback(() => {
|
|
this.bindBlockEvents(block);
|
|
}, { timeout: 2000 });
|
|
}
|
|
|
|
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(BlockRemovedMutationType, this.getBlockByIndex(newIndex), {
|
|
index: newIndex,
|
|
});
|
|
}
|
|
|
|
this._blocks.insert(newIndex, block, replace);
|
|
|
|
/**
|
|
* Force call of didMutated event on Block insertion
|
|
*/
|
|
this.blockDidMutated(BlockAddedMutationType, block, {
|
|
index: newIndex,
|
|
});
|
|
|
|
if (needToFocus) {
|
|
this.currentBlockIndex = newIndex;
|
|
} else if (newIndex <= this.currentBlockIndex) {
|
|
this.currentBlockIndex++;
|
|
}
|
|
|
|
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.
|
|
*
|
|
* If neither data nor tunes is provided, return the provided block instead.
|
|
*
|
|
* @param block - block to update
|
|
* @param data - (optional) new data
|
|
* @param tunes - (optional) tune data
|
|
*/
|
|
public async update(block: Block, data?: Partial<BlockToolData>, tunes?: {[name: string]: BlockTuneData}): Promise<Block> {
|
|
if (!data && !tunes) {
|
|
return block;
|
|
}
|
|
|
|
const existingData = await block.data;
|
|
|
|
const newBlock = this.composeBlock({
|
|
id: block.id,
|
|
tool: block.name,
|
|
data: Object.assign({}, existingData, data ?? {}),
|
|
tunes: 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
|
|
*
|
|
* @param block - block to replace
|
|
* @param newTool - new Tool name
|
|
* @param data - new Tool data
|
|
*/
|
|
public replace(block: Block, newTool: string, data: BlockToolData): Block {
|
|
const blockIndex = this.getBlockIndex(block);
|
|
|
|
return this.insert({
|
|
tool: newTool,
|
|
data,
|
|
index: blockIndex,
|
|
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 {
|
|
/**
|
|
* We need to call onPaste after Block will be ready
|
|
* because onPaste could change tool's root element, and we need to do that after block.watchBlockMutations() bound
|
|
* to detect tool root element change
|
|
*
|
|
* @todo make this.insert() awaitable and remove requestIdleCallback
|
|
*/
|
|
window.requestIdleCallback(() => {
|
|
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(BlockAddedMutationType, 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> {
|
|
let blockToMergeData: BlockToolData | undefined;
|
|
|
|
/**
|
|
* We can merge:
|
|
* 1) Blocks with the same Tool if tool provides merge method
|
|
*/
|
|
if (targetBlock.name === blockToMerge.name && targetBlock.mergeable) {
|
|
const blockToMergeDataRaw = await blockToMerge.data;
|
|
|
|
if (_.isEmpty(blockToMergeDataRaw)) {
|
|
console.error('Could not merge Block. Failed to extract original Block data.');
|
|
|
|
return;
|
|
}
|
|
|
|
const [ cleanData ] = sanitizeBlocks([ blockToMergeDataRaw ], targetBlock.tool.sanitizeConfig);
|
|
|
|
blockToMergeData = cleanData;
|
|
|
|
/**
|
|
* 2) Blocks with different Tools if they provides conversionConfig
|
|
*/
|
|
} else if (targetBlock.mergeable && isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import')) {
|
|
const blockToMergeDataStringified = await blockToMerge.exportDataAsString();
|
|
const cleanData = clean(blockToMergeDataStringified, targetBlock.tool.sanitizeConfig);
|
|
|
|
blockToMergeData = convertStringToBlockData(cleanData, targetBlock.tool.conversionConfig);
|
|
}
|
|
|
|
if (blockToMergeData === undefined) {
|
|
return;
|
|
}
|
|
|
|
await targetBlock.mergeWith(blockToMergeData);
|
|
this.removeBlock(blockToMerge);
|
|
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
|
|
}
|
|
|
|
/**
|
|
* 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, 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');
|
|
}
|
|
|
|
block.destroy();
|
|
this._blocks.remove(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.unsetCurrentBlock();
|
|
|
|
if (addLastBlock) {
|
|
this.insert();
|
|
}
|
|
} else if (index === 0) {
|
|
this.currentBlockIndex = 0;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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(this.blocks[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.unsetCurrentBlock();
|
|
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
|
|
*
|
|
* 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: number): Block | undefined {
|
|
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 | undefined {
|
|
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];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 | undefined {
|
|
if (!childNode || childNode instanceof Node === false) {
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* 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(BlockMovedMutationType, this.currentBlock, {
|
|
fromIndex,
|
|
toIndex,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Converts passed Block to the new Tool
|
|
* Uses Conversion Config
|
|
*
|
|
* @param blockToConvert - Block that should be converted
|
|
* @param targetToolName - name of the Tool to convert to
|
|
* @param blockDataOverrides - optional new Block data overrides
|
|
*/
|
|
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<Block> {
|
|
/**
|
|
* At first, we get current Block data
|
|
*/
|
|
const savedBlock = await blockToConvert.save();
|
|
|
|
if (!savedBlock) {
|
|
throw new Error('Could not convert Block. Failed to extract original Block data.');
|
|
}
|
|
|
|
/**
|
|
* Getting a class of the replacing Tool
|
|
*/
|
|
const replacingTool = this.Editor.Tools.blockTools.get(targetToolName);
|
|
|
|
if (!replacingTool) {
|
|
throw new Error(`Could not convert Block. Tool «${targetToolName}» not found.`);
|
|
}
|
|
|
|
/**
|
|
* Using Conversion Config "export" we get a stringified version of the Block data
|
|
*/
|
|
const exportedData = await blockToConvert.exportDataAsString();
|
|
|
|
/**
|
|
* Clean exported data with replacing sanitizer config
|
|
*/
|
|
const cleanData: string = clean(
|
|
exportedData,
|
|
replacingTool.sanitizeConfig
|
|
);
|
|
|
|
/**
|
|
* Now using Conversion Config "import" we compose a new Block data
|
|
*/
|
|
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig, replacingTool.settings);
|
|
|
|
/**
|
|
* Optional data overrides.
|
|
* Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides
|
|
*/
|
|
if (blockDataOverrides) {
|
|
newBlockData = Object.assign(newBlockData, blockDataOverrides);
|
|
}
|
|
|
|
return this.replace(blockToConvert, replacingTool.name, newBlockData);
|
|
}
|
|
|
|
/**
|
|
* Sets current Block Index -1 which means unknown
|
|
* and clear highlights
|
|
*/
|
|
public unsetCurrentBlock(): void {
|
|
this.currentBlockIndex = -1;
|
|
}
|
|
|
|
/**
|
|
* 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 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.unsetCurrentBlock();
|
|
|
|
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(BlockChangedMutationType, 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 detailData - additional data to pass with change event
|
|
*/
|
|
private blockDidMutated<Type extends BlockMutationType>(mutationType: Type, block: Block, detailData: BlockMutationEventDetailWithoutTarget<Type>): Block {
|
|
const event = new CustomEvent(mutationType, {
|
|
detail: {
|
|
target: new BlockAPI(block),
|
|
...detailData as BlockMutationEventDetailWithoutTarget<Type>,
|
|
},
|
|
});
|
|
|
|
this.eventsDispatcher.emit(BlockChanged, {
|
|
event: event as BlockMutationEventMap[Type],
|
|
});
|
|
|
|
return block;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Type alias for Block Mutation event without 'target' field, used in 'blockDidMutated' method
|
|
*/
|
|
type BlockMutationEventDetailWithoutTarget<Type extends BlockMutationType> = Omit<BlockMutationEventMap[Type]['detail'], 'target'>;
|