/** * @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 { 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 { 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 = {}): Block { const event = new CustomEvent(mutationType, { detail: { target: new BlockAPI(block), ...details, }, }); this.Editor.ModificationsObserver.onChange(event); return block; } }