import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { ExtensionRegistry, DOMUtils } from 'mailspring-exports'; import { DropZone, ScrollRegion, Contenteditable } from 'mailspring-component-kit'; /** * Renders the text editor for the composer * Any component registering in the ComponentRegistry with the role * 'Composer:Editor' will receive these set of props. * * In order for the Composer to work correctly and have a complete set of * functionality (like file pasting), any registered editor *must* call the * provided callbacks at the appropriate time. * * @param {object} props - props for ComposerEditor * @param {string} props.body - Html string with the draft content to be * rendered by the editor * @param {string} props.headerMessageId - Id of the draft being currently edited * @param {object} props.parentActions - Object containg helper actions * associated with the parent container * @param {props.parentActions.getComposerBoundingRect} props.parentActions.getComposerBoundingRect * @param {props.parentActions.scrollTo} props.parentActions.scrollTo * @param {props.onFilePaste} props.onFilePaste * @param {props.onBodyChanged} props.onBodyChanged * @class ComposerEditor */ const NODE_END = false; const NODE_BEGINNING = true; class ComposerEditor extends Component { static displayName = 'ComposerEditor'; /** * This function will return the {DOMRect} for the parent component * @function * @name props.parentActions.getComposerBoundingRect */ /** * This function will make the screen scrollTo the desired position in the * message list * @function * @name props.parentActions.scrollTo * @param {object} options * @param {string} options.id - Id of the message we want to scroll to * @param {string} [options.positon] - If id is provided, this optional * parameter will indicate what position of the message to scrollTo. See * {ScrollRegion} * @param {DOMRect} options.rect - Bounding rect we want to scroll to */ /** * This function should be called when the user pastes a file into the editing * region * @callback props.onFilePaste */ /** * This function should be called when the body of the draft changes, i.e. * when the editor is being typed into. It should pass in an object that looks * like a DOM Event with the current value of the content. * @callback props.onBodyChanged * @param {object} event - DOMEvent-like object that contains information * about the current value of the body * @param {string} event.target.value - HTML string that represents the * current content of the editor body */ static propTypes = { body: PropTypes.string.isRequired, headerMessageId: PropTypes.string, onFilePaste: PropTypes.func, onBodyChanged: PropTypes.func, parentActions: PropTypes.shape({ scrollTo: PropTypes.func, getComposerBoundingRect: PropTypes.func, }), }; constructor(props) { super(props); this.state = { extensions: ExtensionRegistry.Composer.extensions(), }; } componentDidMount() { this.unsub = ExtensionRegistry.Composer.listen(this._onExtensionsChanged); } componentWillUnmount() { this.unsub(); } // Public methods // TODO Get rid of these selection methods getCurrentSelection() { return this._contenteditableComponent.getCurrentSelection(); } getPreviousSelection() { return this._contenteditableComponent.getPreviousSelection(); } setSelection(selection) { this._contenteditableComponent.setSelection(selection); } focus() { // focus the composer and place the insertion point at the last text node of // the body. Be sure to choose the last node /above/ the signature and any // quoted text that is visible. (as in forwarded messages.) // this._contenteditableComponent.atomicEdit(({ editor }) => { editor.rootNode.focus(); const lastNode = this._findLastNodeBeforeQuoteOrSignature(editor); if (lastNode) { this._selectNode(lastNode, { collapseTo: NODE_END }); } else { this._selectNode(editor.rootNode, { collapseTo: NODE_BEGINNING }); } }); } focusAbsoluteEnd() { this._contenteditableComponent.atomicEdit(({ editor }) => { editor.rootNode.focus(); this._selectNode(editor.rootNode, { collapseTo: NODE_END }); }); } // Note: This method returns null for new drafts, because the leading //
tags contain no text nodes. _findLastNodeBeforeQuoteOrSignature(editor) { const walker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT); const nodesBelowUserBody = editor.rootNode.querySelectorAll( 'signature, .gmail_quote, blockquote' ); let lastNode = null; let node = walker.nextNode(); while (node != null) { let belowUserBody = false; for (let i = 0; i < nodesBelowUserBody.length; ++i) { if (nodesBelowUserBody[i].contains(node)) { belowUserBody = true; break; } } if (belowUserBody) { break; } lastNode = node; node = walker.nextNode(); } return lastNode; } _selectNode(node, { collapseTo } = {}) { const range = document.createRange(); range.selectNodeContents(node); range.collapse(collapseTo); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } /** * @private * This method was included so that the tests don't break * TODO refactor the tests! */ _onDOMMutated(mutations) { this._contenteditableComponent._onDOMMutated(mutations); } _onDrop = event => { this._contenteditableComponent._onDrop(event); }; _onDragOver = event => { this._contenteditableComponent._onDragOver(event); }; _shouldAcceptDrop = event => { return this._contenteditableComponent._shouldAcceptDrop(event); }; // Helpers _scrollToBottom = () => { this.props.parentActions.scrollTo({ headerMessageId: this.props.headerMessageId, position: ScrollRegion.ScrollPosition.Bottom, }); }; /** * @private * If the bottom of the container we're scrolling to is really far away * from the contenteditable and your scroll position, we don't want to * jump away. This can commonly happen if the composer has a very tall * image attachment. The "send" button may be 1000px away from the bottom * of the contenteditable. props.parentActions.scrollToBottom moves to the bottom of * the "send" button. */ _bottomIsNearby = editableNode => { const parentRect = this.props.parentActions.getComposerBoundingRect(); const selfRect = editableNode.getBoundingClientRect(); return Math.abs(parentRect.bottom - selfRect.bottom) <= 250; }; /** * @private * As you're typing a lot of content and the cursor begins to scroll off * to the bottom, we want to make it look like we're tracking your * typing. */ _shouldScrollToBottom(selection, editableNode) { return ( this.props.parentActions.scrollTo != null && DOMUtils.atEndOfContent(selection, editableNode) && this._bottomIsNearby(editableNode) ); } /** * @private * When the selectionState gets set (e.g. undo-ing and * redo-ing) we need to make sure it's visible to the user. * * Unfortunately, we can't use the native `scrollIntoView` because it * naively scrolls the whole window and doesn't know not to scroll if * it's already in view. There's a new native method called * `scrollIntoViewIfNeeded`, but this only works when the scroll * container is a direct parent of the requested element. In this case * the scroll container may be many levels up. */ _ensureSelectionVisible = (selection, editableNode) => { // If our parent supports scroll, check for that if (this._shouldScrollToBottom(selection, editableNode)) { this._scrollToBottom(); } else if (this.props.parentActions.scrollTo != null) { // Don't bother computing client rects if no scroll method has been provided const rangeInScope = DOMUtils.getRangeInScope(editableNode); if (!rangeInScope) return; let rect = rangeInScope.getBoundingClientRect(); if (DOMUtils.isEmptyBoundingRect(rect)) { rect = DOMUtils.getSelectionRectFromDOM(selection); } if (rect) { this.props.parentActions.scrollTo({ rect }); } } }; // Handlers _onExtensionsChanged = () => { this.setState({ extensions: ExtensionRegistry.Composer.extensions() }); }; // Renderers render() { return ( { if (cm) { this._contenteditableComponent = cm; } }} value={this.props.body} onChange={this.props.onBodyChanged} onFilePaste={this.props.onFilePaste} onSelectionRestored={this._ensureSelectionVisible} extensions={this.state.extensions} /> ); } } ComposerEditor.containerRequired = false; export default ComposerEditor;