/** * 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: * * * * * * * @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 - * @property {Element} nodes.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 { 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} */ private async make(): Promise { /** * 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); } }