editor.js/src/components/modules/ui.ts
2019-06-12 20:38:16 +03:00

532 lines
14 KiB
TypeScript

/**
* Prebuilded sprite of SVG icons
*/
import sprite from '../../../dist/sprite.svg';
/**
* Module UI
*
* @type {UI}
*/
import Module from '../__module';
import $ from '../dom';
import _ from '../utils';
import Selection from '../selection';
/**
* @class
*
* @classdesc Makes Editor.js UI:
* <codex-editor>
* <ce-redactor />
* <ce-toolbar />
* <ce-inline-toolbar />
* </codex-editor>
*
* @typedef {UI} UI
* @property {EditorConfig} config - editor configuration {@link EditorJS#configuration}
* @property {Object} Editor - available editor modules {@link EditorJS#moduleInstances}
* @property {Object} nodes -
* @property {Element} nodes.holder - element where we need to append redactor
* @property {Element} nodes.wrapper - <codex-editor>
* @property {Element} nodes.redactor - <ce-redactor>
*/
export default class UI extends Module {
/**
* Editor.js UI CSS class names
* @return {{editorWrapper: string, editorZone: string}}
*/
public get CSS(): {
editorWrapper: string, editorWrapperNarrow: string, editorZone: string, editorZoneHidden: string,
editorLoader: string, editorEmpty: string,
} {
return {
editorWrapper : 'codex-editor',
editorWrapperNarrow : 'codex-editor--narrow',
editorZone : 'codex-editor__redactor',
editorZoneHidden : 'codex-editor__redactor--hidden',
editorLoader : 'codex-editor__loader',
editorEmpty : 'codex-editor--empty',
};
}
/**
* Width of center column of Editor
* @type {number}
*/
public contentWidth: number = 650;
/**
* HTML Elements used for UI
*/
public nodes: { [key: string]: HTMLElement } = {
holder: null,
wrapper: null,
redactor: null,
};
/**
* Adds loader to editor while content is not ready
*/
public addLoader(): void {
this.nodes.loader = $.make('div', this.CSS.editorLoader);
this.nodes.wrapper.prepend(this.nodes.loader);
this.nodes.redactor.classList.add(this.CSS.editorZoneHidden);
}
/**
* Removes loader when content has loaded
*/
public removeLoader(): void {
this.nodes.loader.remove();
this.nodes.redactor.classList.remove(this.CSS.editorZoneHidden);
}
/**
* Making main interface
*/
public async prepare(): Promise<void> {
await this.make();
this.addLoader();
/**
* Append SVG sprite
*/
await this.appendSVGSprite();
/**
* Make toolbar
*/
await this.Editor.Toolbar.make();
/**
* Make the Inline toolbar
*/
await this.Editor.InlineToolbar.make();
/**
* Load and append CSS
*/
await this.loadStyles();
/**
* Bind events for the UI elements
*/
await this.bindEvents();
}
/**
* Check if Editor is empty and set CSS class to wrapper
*/
public checkEmptiness(): void {
const {BlockManager} = this.Editor;
this.nodes.wrapper.classList.toggle(this.CSS.editorEmpty, BlockManager.isEditorEmpty);
}
/**
* Clean editor`s UI
*/
public destroy(): void {
this.nodes.holder.innerHTML = '';
}
/**
* Makes Editor.js interface
* @return {Promise<void>}
*/
private async make(): Promise<void> {
/**
* Element where we need to append Editor.js
* @type {Element}
*/
this.nodes.holder = $.getHolder(this.config.holder);
/**
* Create and save main UI elements
*/
this.nodes.wrapper = $.make('div', this.CSS.editorWrapper);
this.nodes.redactor = $.make('div', this.CSS.editorZone);
/**
* If Editor has injected into the narrow container, enable Narrow Mode
*/
if (this.nodes.holder.offsetWidth < this.contentWidth) {
this.nodes.wrapper.classList.add(this.CSS.editorWrapperNarrow);
}
/**
* Set customizable bottom zone height
*/
this.nodes.redactor.style.paddingBottom = this.config.minHeight + 'px';
this.nodes.wrapper.appendChild(this.nodes.redactor);
this.nodes.holder.appendChild(this.nodes.wrapper);
}
/**
* Appends CSS
*/
private loadStyles(): void {
/**
* Load CSS
*/
const styles = require('../../styles/main.css');
/**
* Make tag
*/
const tag = $.make('style', null, {
textContent: styles.toString(),
});
/**
* Append styles at the top of HEAD tag
*/
$.prepend(document.head, tag);
}
/**
* Bind events on the Editor.js interface
*/
private bindEvents(): void {
this.Editor.Listeners.on(
this.nodes.redactor,
'click',
(event) => this.redactorClicked(event as MouseEvent),
false,
);
this.Editor.Listeners.on(document, 'keydown', (event) => this.documentKeydown(event as KeyboardEvent), true);
this.Editor.Listeners.on(document, 'click', (event) => this.documentClicked(event as MouseEvent), true);
}
/**
* All keydowns on document
* @param {Event} event
*/
private documentKeydown(event: KeyboardEvent): void {
switch (event.keyCode) {
case _.keyCodes.ENTER:
this.enterPressed(event);
break;
case _.keyCodes.BACKSPACE:
this.backspacePressed(event);
break;
default:
this.defaultBehaviour(event);
break;
}
}
/**
* Ignore all other document's keydown events
* @param {KeyboardEvent} event
*/
private defaultBehaviour(event: KeyboardEvent): void {
const keyDownOnEditor = (event.target as HTMLElement).closest(`.${this.CSS.editorWrapper}`);
const {currentBlock} = this.Editor.BlockManager;
const isMetaKey = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
/**
* Ignore keydowns on editor and meta keys
*/
if (keyDownOnEditor || (currentBlock && isMetaKey)) {
return;
}
/**
* Remove all highlights and remove caret
*/
this.Editor.BlockManager.dropPointer();
/**
* Close Toolbar
*/
this.Editor.Toolbar.close();
}
/**
* @param {KeyboardEvent} event
*/
private backspacePressed(event: KeyboardEvent): void {
const {BlockManager, BlockSelection, Caret} = this.Editor;
if (BlockSelection.anyBlockSelected) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
Caret.setToBlock(BlockManager.insertAtIndex(selectionPositionIndex, true), Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection();
/**
* Stop propagations
* Manipulation with BlockSelections is handled in global backspacePress because they may occur
* with CMD+A or RectangleSelection and they can be handled on document event
*/
event.stopPropagation();
event.stopImmediatePropagation();
}
}
/**
* Enter pressed on document
* @param event
*/
private enterPressed(event: KeyboardEvent): void {
const {BlockManager, BlockSelection, Caret, BlockSettings} = this.Editor;
const hasPointerToBlock = BlockManager.currentBlockIndex >= 0;
/**
* If Block Settings is opened and have some active button
* Enter press is fired as out of the Block and that's why
* we handle it here
*/
if (BlockSettings.opened && BlockSettings.focusedButton) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
/** Click on settings button */
BlockSettings.focusedButton.click();
/**
* Add animation on click
*/
BlockSettings.focusedButton.classList.add(BlockSettings.CSS.focusedButtonAnimated);
/**
* Remove animation class
*/
_.delay( () => {
BlockSettings.focusedButton.classList.remove(BlockSettings.CSS.focusedButtonAnimated);
}, 280)();
/**
* Restoring focus on current Block
*
* After changing Block state (when settings clicked, for example)
* Block's content points to the Node that is not in DOM, that's why we can not
* set caret and leaf next (via Tab)
*
* For that set cursor via Caret module to the current Block's content
* after some timeout
*/
_.delay( () => {
Caret.setToBlock(BlockManager.currentBlock);
}, 10)();
return;
}
if (BlockSelection.anyBlockSelected) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
Caret.setToBlock(BlockManager.insertAtIndex(selectionPositionIndex, true), Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection();
/**
* Stop propagations
* Manipulation with BlockSelections is handled in global enterPress because they may occur
* with CMD+A or RectangleSelection
*/
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
return;
}
/**
* If Caret is not set anywhere, event target on Enter is always Element that we handle
* In our case it is document.body
*
* So, BlockManager points some Block and Enter press is on Body
* We can create a new block
*/
if (hasPointerToBlock && (event.target as HTMLElement).tagName === 'BODY') {
/**
* Insert initial typed Block
*/
const newBlock = this.Editor.BlockManager.insert();
this.Editor.Caret.setToBlock(newBlock);
/**
* And highlight
*/
this.Editor.BlockManager.highlightCurrentNode();
/**
* Move toolbar and show plus button because new Block is empty
*/
this.Editor.Toolbar.move();
this.Editor.Toolbar.plusButton.show();
}
this.Editor.BlockSelection.clearSelection();
}
/**
* All clicks on document
* @param {MouseEvent} event - Click
*/
private documentClicked(event: MouseEvent): void {
/**
* Close Inline Toolbar when nothing selected
* Do not fire check on clicks at the Inline Toolbar buttons
*/
const target = event.target as HTMLElement;
const clickedOnInlineToolbarButton = target.closest(`.${this.Editor.InlineToolbar.CSS.inlineToolbar}`);
const clickedInsideOfEditor = this.nodes.holder.contains(target) || Selection.isAtEditor;
if (!clickedInsideOfEditor) {
/**
* Clear highlightings and pointer on BlockManager
*
* Current page might contain several instances
* Click between instances MUST clear focus, pointers and close toolbars
*/
this.Editor.BlockManager.dropPointer();
this.Editor.InlineToolbar.close();
this.Editor.Toolbar.close();
this.Editor.BlockSelection.clearSelection();
} else if (!clickedOnInlineToolbarButton) {
/**
* Move inline toolbar to the focused Block
*/
this.Editor.InlineToolbar.handleShowingEvent(event);
}
if (Selection.isAtEditor) {
/**
* Focus clicked Block.
* Workaround case when user clicks on the bottom of editor
*/
if (Selection.anchorNode === this.nodes.redactor) {
this.Editor.Caret.setToTheLastBlock();
} else {
this.Editor.BlockManager.setCurrentBlockByChildNode(Selection.anchorNode);
}
}
}
/**
* All clicks on the redactor zone
*
* @param {MouseEvent} event
*
* @description
* 1. Save clicked Block as a current {@link BlockManager#currentNode}
* it uses for the following:
* - add CSS modifier for the selected Block
* - on Enter press, we make a new Block under that
*
* 2. Move and show the Toolbar
*
* 3. Set a Caret
*
* 4. By clicks on the Editor's bottom zone:
* - if last Block is empty, set a Caret to this
* - otherwise, add a new empty Block and set a Caret to that
*
* 5. Hide the Inline Toolbar
*
* @see selectClickedBlock
*
*/
private redactorClicked(event: MouseEvent): void {
if (!Selection.isCollapsed) {
return;
}
let clickedNode = event.target as HTMLElement;
/**
* If click was fired is on Editor`s wrapper, try to get clicked node by elementFromPoint method
*/
if (clickedNode === this.nodes.redactor) {
clickedNode = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement;
}
/**
* Select clicked Block as Current
*/
try {
/**
* Renew Current Block
*/
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
/**
* Highlight Current Node
*/
this.Editor.BlockManager.highlightCurrentNode();
} catch (e) {
/**
* If clicked outside first-level Blocks and it is not RectSelection, set Caret to the last empty Block
*/
if (!this.Editor.RectangleSelection.isRectActivated()) {
this.Editor.Caret.setToTheLastBlock();
}
}
event.stopImmediatePropagation();
event.stopPropagation();
/**
* Move and open toolbar
*/
this.Editor.Toolbar.open();
/**
* Hide the Plus Button
*/
this.Editor.Toolbar.plusButton.hide();
if (!this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.insert();
}
/**
* Show the Plus Button if:
* - Block is an initial-block (Text)
* - Block is empty
*/
const isInitialBlock = this.Editor.Tools.isInitial(this.Editor.BlockManager.currentBlock.tool);
if (isInitialBlock) {
/**
* Check isEmpty only for paragraphs to prevent unnecessary tree-walking on Tools with many nodes (for ex. Table)
*/
const isEmptyBlock = this.Editor.BlockManager.currentBlock.isEmpty;
if (isEmptyBlock) {
this.Editor.Toolbar.plusButton.show();
}
}
/** Clear selection */
this.Editor.BlockSelection.clearSelection();
}
/**
* Append prebuilded sprite with SVG icons
*/
private appendSVGSprite(): void {
const spriteHolder = $.make('div');
spriteHolder.hidden = true;
spriteHolder.style.display = 'none';
spriteHolder.innerHTML = sprite;
$.append(this.nodes.wrapper, spriteHolder);
}
}