editor.js/src/components/modules/blockManager.ts

599 lines
14 KiB
TypeScript
Raw Normal View History

/**
* @class BlockManager
* @classdesc Manage editor`s blocks storage and appearance
*
* @module BlockManager
*
* @version 2.0.0
*/
import Block from '../block';
import Module from '../__module';
import $ from '../dom';
import _ from '../utils';
import Blocks from '../blocks';
import {BlockTool, BlockToolConstructable, BlockToolData, PasteEvent, ToolConfig} from '../../../types';
/**
* @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 {
2019-01-12 02:57:37 +01:00
/**
* Returns current Block index
* @return {number}
*/
public get currentBlockIndex(): number {
return this._currentBlockIndex;
}
/**
* Set current Block index and fire Block lifecycle callbacks
* @param newIndex
*/
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
* @return {Block}
*/
public get firstBlock(): Block {
return this._blocks[0];
}
/**
* returns last Block
* @return {Block}
*/
public get lastBlock(): Block {
return this._blocks[this._blocks.length - 1];
}
/**
* Get current Block instance
*
* @return {Block}
*/
public get currentBlock(): Block {
return this._blocks[this.currentBlockIndex];
}
/**
* Returns next Block instance
* @return {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
* @return {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}
*/
2019-01-12 02:57:37 +01:00
private _currentBlockIndex: number = -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
*
* @returns {Promise}
*/
public async prepare() {
const blocks = new Blocks(this.Editor.UI.nodes.redactor);
const { BlockEvents, Shortcuts } = this.Editor;
/**
* 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 shortcut */
Shortcuts.add({
name: 'CMD+C',
handler: (event) => {
BlockEvents.handleCommandC(event);
},
});
/** Copy and cut */
Shortcuts.add({
name: 'CMD+X',
handler: (event) => {
BlockEvents.handleCommandX(event);
},
});
}
/**
* Creates Block instance by tool name
*
* @param {String} toolName - tools passed in editor config {@link EditorConfig#tools}
* @param {Object} data - constructor params
* @param {Object} settings - block settings
*
* @return {Block}
*/
public composeBlock(toolName: string, data: BlockToolData = {}, settings: ToolConfig = {}): Block {
const toolInstance = this.Editor.Tools.construct(toolName, data) as BlockTool;
const toolClass = this.Editor.Tools.available[toolName] as BlockToolConstructable;
const block = new Block(toolName, toolInstance, toolClass, settings, this.Editor.API.methods);
this.bindEvents(block);
return block;
}
/**
* Insert new block into _blocks
*
* @param {String} toolName plugin name, by default method inserts initial block type
* @param {Object} data plugin data
* @param {Object} settings - default settings
*
* @return {Block}
*/
public insert(
toolName: string = this.config.initialBlock,
data: BlockToolData = {},
settings: ToolConfig = {},
): Block {
// Increment index before construct,
// because developers can use API/Blocks/getCurrentInputIndex on the render() method
const newIndex = ++this.currentBlockIndex;
const block = this.composeBlock(toolName, data, settings);
this._blocks[newIndex] = block;
return block;
}
/**
* Insert pasted content. Call onPaste callback after insert.
*
* @param {string} toolName
* @param {PasteEvent} pasteEvent - pasted data
* @param {boolean} replace - should replace current block
*/
public paste(
toolName: string,
pasteEvent: PasteEvent,
replace: boolean = false,
): Block {
let block;
if (replace) {
block = this.replace(toolName);
} else {
block = this.insert(toolName);
}
try {
block.call('onPaste', pasteEvent);
} catch (e) {
_.log(`${toolName}: onPaste callback call is failed`, 'error', e);
}
return block;
}
2019-01-12 02:57:37 +01:00
/**
* Insert new initial block at passed index
*
* @param {number} index - index where Block should be inserted
* @param {boolean} needToFocus - if true, updates current Block index
*
* @return {Block} inserted Block
*/
public insertAtIndex(index: number, needToFocus: boolean = false) {
const block = this.composeBlock(this.config.initialBlock, {}, {});
this._blocks[index] = block;
if (needToFocus) {
this.currentBlockIndex = index;
} else if (index <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
return block;
}
/**
* Always inserts at the end
* @return {Block}
*/
public insertAtEnd(): Block {
/**
* Define new value for current block index
*/
this.currentBlockIndex = this.blocks.length - 1;
/**
* Insert initial 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
*
* @return {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
*/
public removeBlock(index?: number): void {
2019-01-12 02:57:37 +01:00
if (index === undefined) {
index = this.currentBlockIndex;
}
this._blocks.remove(index);
2018-12-25 16:07:05 +01:00
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();
2019-01-12 02:57:37 +01:00
return;
} else if (index === 0) {
this.currentBlockIndex = 0;
}
}
/**
* Remove only selected Blocks
* and returns first Block index where started removing...
* @return 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 new initial 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
*
* @return {Block}
*/
public split(): Block {
const extractedFragment = this.Editor.Caret.extractFragmentFromCaretPosition();
const wrapper = $.make('div');
2019-01-12 02:57:37 +01:00
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(this.config.initialBlock, data);
}
/**
* Replace current working block
*
* @param {String} toolName plugin name
* @param {Object} data plugin data
*
* @return {Block}
*/
public replace(
toolName: string = this.config.initialBlock,
data: BlockToolData = {},
): Block {
const block = this.composeBlock(toolName, data);
this._blocks.insert(this.currentBlockIndex, block, true);
return block;
}
/**
* Returns Block by passed index
* @param {Number} index
* @return {Block}
*/
public getBlockByIndex(index): Block {
return this._blocks[index];
}
/**
* Get Block instance by html element
* @param {Node} element
* @returns {Block}
*/
public getBlock(element: HTMLElement): Block {
Version 2.13 (#719) * Do not start multi-block selection on UI elements (#662) * Do not start multi-block selection on UI elements * Do not prevent mousedown event on inline toolbar actions * Remove log * Add comment * Add link to issue closes #646 * Fix loss of pointer (#666) * Fix loss of pointer when click is outside of the editor but selection is inside * Remove log * Update shortcuts module (#685) * Fixed possible grammatical typo (#681) Thanks * Update shortcuts module * update changelog * update * Remove margin top for inline-link icon (#690) * Remove margin top for inline-link icon resolves #674 * Update CHANGELOG.md * Remove unused style * Pull fresh tools * Remove changelog contents from readme (#700) * #665 API to open and close inline-toolbar (#711) * API to open and close inline-toolbar * Fixed documentation * renamed inline -> inline-toolbar * removed dist * reset editor.js * added editor.js bundle * Fixed build error * Null checks on toolbar/inline@open * updated bundle * Improve some comments * Updatd api.md CHANGELOG.md * Change feature to new instead of improvement * Allow holderId work with ref on dom element (#710) * done * update types * attempt to simplify code * remove useless helper * revert holderId logic and add holder property * Apply suggestions from code review Co-Authored-By: dimensi <eddimensi@gmail.com> * update holder type on string | HTMLElement * fix typo * add deprecated notice and fix typos * fix wrong compare * fix comments * swap console.log on _.log * update types for editor config * update examples * update docs * update build * Activating Open Collective (#736) Hi, I'm making updates for Open Collective. Either you or a supporter signed this repo up for Open Collective. This pull request adds backers and sponsors from your Open Collective https://opencollective.com/editorjs❤️ It adds two badges at the top to show the latest number of backers and sponsors. It also adds placeholders so that the avatar/logo of new backers/sponsors can automatically be shown without having to update your README.md. [more info](https://github.com/opencollective/opencollective/wiki/Github-banner). See how it looks on this [repo](https://github.com/apex/apex#backers). You can also add a postinstall script to let people know after npm|yarn install that you are welcoming donations (optional). [More info](https://github.com/OpenCollective/opencollective-cli) You can also add a "Donate" button to your website and automatically show your backers and sponsors there with our widgets. Have a look here: https://opencollective.com/widgets P.S: As with any pull request, feel free to comment or suggest changes. The only thing "required" are the placeholders on the README because we believe it's important to acknowledge the people in your community that are contributing (financially or with code!). Thank you for your great contribution to the open source community. You are awesome! 🙌 And welcome to the open collective community! 😊 Come chat with us in the #opensource channel on https://slack.opencollective.com - great place to ask questions and share best practices with other open source sustainers! * Do not install editor.js as dev-dependency (#731) Resolves #730 * Move codex-notifier to dependencies for typescript declarations (#728) * Close inline toolbar after creating new link by pressing ENTER (#722) * Method to clear current selection and close inline toolbar * clearSelection with optional collapsed range * refactored selection.ts * removed experimental function * Update src/components/selection.ts Co-Authored-By: tanmayv <12tanmayvijay@gmail.com> * update version, add changelog * Link Logo Image to homepage (#738) * Update README.md (#744) * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md Co-Authored-By: neSpecc <specc.dev@gmail.com> * Config minHeight option that allows to customize bottom zone (#745) * issue-739: allow Block's editable element selection (#747) * issue-739: allow Block's input selection * little improvements * update Changelog and cache inputs * small fix * delete map file * fix inputs.count condition * Fix typo in example paragraph (#749) * Fix typo * Update example-dev.html * minor release
2019-04-29 14:52:54 +02:00
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.
* @param {string} caretPosition - position where to set caret
* @throws Error - when passed Node is not included at the Block
*/
2019-01-12 02:57:37 +01:00
public setCurrentBlockByChildNode(childNode: Node): Block {
/**
* If node is Text TextNode
*/
if (!$.isElement(childNode)) {
childNode = childNode.parentNode;
}
const parentFirstLevelBlock = (childNode as HTMLElement).closest(`.${Block.CSS.wrapper}`);
if (parentFirstLevelBlock) {
/**
* Update current Block's index
* @type {number}
*/
this.currentBlockIndex = this._blocks.nodes.indexOf(parentFirstLevelBlock as HTMLElement);
2019-01-12 02:57:37 +01:00
return this.currentBlock;
} else {
throw new Error('Can not find a Block from this child Node');
}
}
/**
* Return block which contents passed node
*
* @param {Node} childNode
* @return {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
* @param {Number} toIndex
*/
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;
}
/**
* Sets current Block Index -1 which means unknown
* and clear highlightings
*/
public dropPointer(): void {
this.currentBlockIndex = -1;
this.clearFocused();
}
/**
* Clears Editor
* @param {boolean} needAddInitialBlock - 1) in internal calls (for example, in api.blocks.render)
* we don't need to add empty initial block
* 2) in api.blocks.clear we should add empty block
*/
public clear(needAddInitialBlock: boolean = false): void {
this._blocks.removeAll();
this.dropPointer();
if (needAddInitialBlock) {
this.insert(this.config.initialBlock);
}
2019-06-12 19:38:16 +02:00
/**
* Add empty modifier
*/
this.Editor.UI.checkEmptiness();
}
/**
* Bind Events
* @param {Object} block
*/
private bindEvents(block: Block): void {
const {BlockEvents, Listeners} = this.Editor;
Listeners.on(block.holder, 'keydown', (event) => BlockEvents.keydown(event as KeyboardEvent), true);
Listeners.on(block.holder, 'mouseup', (event) => BlockEvents.mouseUp(event));
Listeners.on(block.holder, 'keyup', (event) => BlockEvents.keyup(event));
Listeners.on(block.holder, 'dragover', (event) => BlockEvents.dragOver(event as DragEvent));
Listeners.on(block.holder, 'dragleave', (event) => BlockEvents.dragLeave(event as DragEvent));
}
}