Mailspring/internal_packages/composer/lib/composer-editor.jsx
Juan Tejada 2dccde2e58 feat(mail-merge): Add mail merge plugin
Summary:
Adds Mail Merge Plugin
- Adds new table components to component kit
- Adds new extension points to allow dragging and dropping into composer contenteditable and participant fields and customizing participant fields
- Adds new decorators and other misc updates
- #1608

Test Plan: TODO

Reviewers: bengotow, evan

Reviewed By: bengotow, evan

Differential Revision: https://phab.nylas.com/D2895
2016-04-22 18:29:07 -07:00

324 lines
10 KiB
JavaScript

import React, {Component, PropTypes} from 'react';
import {ContenteditableExtension, ExtensionRegistry, DOMUtils} from 'nylas-exports';
import {DropZone, ScrollRegion, Contenteditable} from 'nylas-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.draftClientId - Id of the draft being currently edited
* @param {object} props.initialSelectionSnapshot - Initial content selection
* that was previously saved
* @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.onFocus} props.onFocus
* @param {props.onFilePaste} props.onFilePaste
* @param {props.onBodyChanged} props.onBodyChanged
* @class ComposerEditor
*/
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.clientId - Id of the message we want to scroll to
* @param {string} [options.positon] - If clientId 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 editing region is focused by the user
* @callback props.onFocus
*/
/**
* 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,
draftClientId: PropTypes.string,
initialSelectionSnapshot: PropTypes.object,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
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(),
};
class ComposerFocusManager extends ContenteditableExtension {
static onFocus() {
if (props.onFocus) {
return props.onFocus();
}
}
static onBlur() {
if (props.onBlur) {
return props.onBlur();
}
}
}
this._coreExtension = ComposerFocusManager;
}
componentDidMount() {
this.unsub = ExtensionRegistry.Composer.listen(this._onExtensionsChanged);
}
componentWillUnmount() {
this.unsub();
}
// Public methods
/**
* @private
* Methods in ES6 classes should be defined using object method shorthand
* syntax (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Method_definitions),
* as opposed to arrow function syntax, if we want them to be enumerated
* on the prototype. Arrow function syntax is used to lexically bind the `this`
* value, and are specialized for non-method callbacks, where them picking up
* the this of their surrounding method or constructor is an advantage.
* See https://goo.gl/9ZMOGl for an example
* and http://www.2ality.com/2015/02/es6-classes-final.html for more info.
*/
// TODO Get rid of these selection methods
getCurrentSelection() {
return this.refs.contenteditable.getCurrentSelection();
}
getPreviousSelection() {
return this.refs.contenteditable.getPreviousSelection();
}
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.refs.contenteditable.atomicEdit( ({editor})=> {
editor.rootNode.focus();
const lastNode = this._findLastNodeBeforeQuoteOrSignature(editor)
this._selectEndOfNode(lastNode)
});
}
_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
}
_findAbsoluteLastNode(editor) {
const walker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT);
let lastNode = null;
let node = walker.nextNode();
while (node) {
lastNode = node;
node = walker.nextNode();
}
return lastNode;
}
_selectEndOfNode(node) {
if (node) {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
}
focusAbsoluteEnd() {
this.refs.contenteditable.atomicEdit( ({editor})=> {
editor.rootNode.focus();
const lastNode = this._findAbsoluteLastNode(editor)
this._selectEndOfNode(lastNode)
});
}
/**
* @private
* This method was included so that the tests don't break
* TODO refactor the tests!
*/
_onDOMMutated(mutations) {
this.refs.contenteditable._onDOMMutated(mutations);
}
_onDrop(event) {
this.refs.contenteditable._onDrop(event)
}
_onDragOver(event) {
this.refs.contenteditable._onDragOver(event)
}
// Helpers
_scrollToBottom = ()=> {
this.props.parentActions.scrollTo({
clientId: this.props.draftClientId,
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 (
<DropZone
className="composer-inner-wrap"
onDrop={::this._onDrop}
onDragOver={::this._onDragOver}
shouldAcceptDrop={() => true}
>
<Contenteditable
ref="contenteditable"
value={this.props.body}
onChange={this.props.onBodyChanged}
onFilePaste={this.props.onFilePaste}
onSelectionRestored={this._ensureSelectionVisible}
initialSelectionSnapshot={this.props.initialSelectionSnapshot}
extensions={[this._coreExtension].concat(this.state.extensions)}
/>
</DropZone>
);
}
}
ComposerEditor.containerRequired = false
export default ComposerEditor;