Home Reference Source

application/components/common/rich-editor.js

import React from 'react';
import {connect} from 'react-redux';

import moment from 'moment';
import {Modifier, AtomicBlockUtils, Editor, EditorState, RichUtils, ContentState, CompositeDecorator, Entity, getDefaultKeyBinding, KeyBindingUtil, convertToRaw, convertFromRaw} from 'draft-js';
import ExtendedRichUtils, {ALIGNMENT_DATA_KEY} from './rich-editor/libs/extend-rich-utils';
import DropDownMenu from 'material-ui/DropDownMenu';
import MenuItem from 'material-ui/MenuItem';
import IconMenu from 'material-ui/IconMenu';
import FontIcon from 'material-ui/FontIcon';
import SvgIcon from 'material-ui/SvgIcon';
import Subheader from 'material-ui/Subheader';
import FlatButton from 'material-ui/FlatButton';
import Dialog from 'material-ui/Dialog';
import {reduceSlashes} from '../../util/strings';

import CharacterCounter from './../common/character-counter';

import uuid from 'uuid';


import BaseView from './../base-view';

import CurrentUser from './../../current-user';
import Events from './../../util/events';

import {CopyNoteService, CopyNoteStore} from './../../services/copyNote-service';
import NoteCommentFlux from './../../services/noteComment-service';

import {info, error, warning} from './../../util/console';
import {timeToFormat} from './../../util/strings';
import {expandSelection} from './../../util/draft-utils';

// plugin-ish things
import PullQuoteBlock, {insertPullQuote} from './rich-editor/pull-quote';
import HorizontalRuleBlock, {insertHorizontalRule} from './rich-editor/horizontal-rule';
import EmbedBlock, {insertEmbed} from './rich-editor/embed';
import MediaBlock, {insertMedia} from './rich-editor/media';
import LinkRange, {LinkEditor, findLinkEntities, insertLinkEntity, removeLinkEntity} from './rich-editor/link';
import DropCapRange, {findDropCapEntities, toggleDropCapEntity} from './rich-editor/drop-cap';

import SimpleEditor from './simple-editor';
import {stateToHTML} from 'draft-js-export-html';
// import stateToHTML from './../../exportToHTML_modified';
import {stateFromHTML} from 'draft-js-import-html';


const {hasCommandModifier} = KeyBindingUtil;

const h1Icon = (props) => (
    <SvgIcon {...props}>
        <path d="M99.32,297.32H279.84v31.12l-38.28,3.11-10.27,10.27V481.58H415.54V341.83l-9.34-10.27-38.28-3.11V297.32H547.51v31.12l-37,3.11-10,10.27V680.46l10,10,37,3.42V725H367.92V693.84l38.28-3.42,9.34-10V519.86H231.29v160.6l10.27,10,38.28,3.42V725H99.32V693.84l37-3.42,10.27-10V341.83l-10.27-10.27-37-3.11Z" transform="translate(-99.32 -290.79)"/><path d="M771.91,290.79H813.3V680.46l6.85,10,60.07,2.8V725H658.93V693.22l63.8-4,7.47-11.52V364.24l-5.6-7.78L634,369.84V337.47Z" transform="translate(-99.32 -290.79)"/>
    </SvgIcon>
);

/**
 * The rich editor is quite complex and doesn't play well with
 * previously richly formatted text. But what it does handle, it handles
 * with a high degree of Awesome Sauce.
 *
 * @author Mike Joseph <mike@getsnworks.com>
 */
class RichEditor extends BaseView {
    constructor(props) {
        super(props);

        // Decorator builds the display for inline bits in the editor
        this.decorator = new CompositeDecorator([
            {
                'strategy': findLinkEntities,
                'component': LinkRange,
                'props': {
                    onStartEdit: this.linkStartEdit.bind(this),
                    onFinishEdit: this.linkEndEdit.bind(this)
                }
            },
            {
                'strategy': findDropCapEntities,
                'component': DropCapRange,
                'props': {
                    onStartEdit: this.retain.bind(this),
                    onFinishEdit: this.release.bind(this)
                }
            },
            {
                'strategy': findNoteEntities,
                'component': CopyNote
            },
            {
                'strategy': findCopyQuoteEntities,
                'component': CopyQuote
            }
        ]);

        // bind focus so we can use it later
        this.focus = () => this.refs.editor.focus();
        this.onChange = this.onChange.bind(this);

        // Generic rich editing commands and styles
        this.handleKeyCommand = (command) => this._handleKeyCommand(command);
        this.handleBeforeInput = (str, state) => this._handleBeforeInput(str, state);
        this.toggleBlockType = (type) => this._toggleBlockType(type);
        this.toggleInlineStyle = (style) => this._toggleInlineStyle(style);

        this.blockRender = this.blockRender.bind(this);

        // Link modal handlers
        this.promptForLink = this.promptForLink.bind(this);
        this.removeLink = this.removeLink.bind(this);

        // Drop cap
        this.toggleDropCap = this.toggleDropCap.bind(this);

        // Copy notes
        this.promptForNote = this.promptForNote.bind(this);

        // CQs
        this.addCopyQuote = this.addCopyQuote.bind(this);

        this.addPullQuote = this.addPullQuote.bind(this);
        this.addHorizontalRule = this.addHorizontalRule.bind(this);
        this.addEmbed = this.addEmbed.bind(this);
        this.addMedia = this.addMedia.bind(this);

        this.state = {
            isComponentEditing: false,
            isLinkEditorOpen: false,
            linkEntity: null
        };

        this.retain = this.retain.bind(this);
        this.release = this.release.bind(this);
        this.remove = this.remove.bind(this);

        this.editingReferenceCounter = 0;

    }

    linkStartEdit(key) {
        this.retain.call(this);
        this.setState({isLinkEditorOpen: true, linkEntity: key});
    }

    linkEndEdit(url, target) {
        this.release.call(this);

        this.state.editorState.getCurrentContent().mergeEntityData(this.state.linkEntity, {
            url: url,
            isnew: false,
            target: target ? '_blank' : '_self'
        });

        this.setState({isLinkEditorOpen: false, linkEntity: null}, () => {
            const {editorState} = this.state;
            let selection = editorState.getSelection();

            if (!selection.isCollapsed()) {
                return;
            }

            let collapsed = selection.merge({
                anchorOffset: selection.getEndOffset(),
                focusOffset: selection.getEndOffset()
            });

            let newEditorState = EditorState.forceSelection(editorState, collapsed);

            let padder = Modifier.insertText(
                newEditorState.getCurrentContent(),
                newEditorState.getSelection(),
                ''
            );

            this.onChange(EditorState.push(newEditorState, padder, 'insert-text'));
        });
    }

    componentWillReceiveProps(nextProps) {
        let contentState;
        if (nextProps.defaultValue
            && JSON.stringify(nextProps.defaultValue) != JSON.stringify(this.props.defaultValue)
            && nextProps.defaultValue.indexOf('{') === 0)
        {
            contentState = convertFromRaw(JSON.parse(nextProps.defaultValue));

            info('Resetting editor content from draft');
            this.setState({editorState: EditorState.createWithContent(contentState, this.decorator)});
        } else if (nextProps.defaultValue
            && typeof nextProps.defaultValue == 'string'
            && nextProps.defaultValue != this.props.defaultValue) {

            if (nextProps.defaultValue.indexOf('<p') !== -1) {
                // It's html
                contentState = stateFromHTML(nextProps.defaultValue);
            } else {
                // Otherwise just treat it as a string
                contentState = ContentState.createFromText(nextProps.defaultValue);
            }
            this.setState({editorState: EditorState.createWithContent(contentState, this.decorator)});
        }
    }

    /**
     * Custom block components must call this to lock the edit state
     * of the editor to prevent unwanted interaction while dealing with
     * the contents of the custom block
     */
    retain() {
        console.log('REFCOUNT RETAIN');
        this.editingReferenceCounter += 1;
        if (this.editingReferenceCounter === 1) {
            this.setState({isComponentEditing: true});
        }
        if (this.props.onStateChange) {
            this.props.onStateChange(this.editingReferenceCounter, 'retain');
        }
    }

    /**
     * Custom block components must call this to release the edit state lock.
     * Uses reference counting to ensure that locks are not prematurely released
     */
    release() {
        console.log('REFCOUNT RELEASE');
        this.editingReferenceCounter -= 1;
        if (this.editingReferenceCounter <= 0) {
            this.setState({isComponentEditing: false});
        }
        setTimeout(() => this.refs.editor.focus(), 0)
        if (this.props.onStateChange) {
            this.props.onStateChange(this.editingReferenceCounter, 'release');
        }
    }

    /**
     *
     */
    remove() {
        console.log('REFCOUNT REMOVE');
        const {editorState} = this.state;
        let selectionState = editorState.getSelection();

        let contentState = editorState.getCurrentContent();
        let selStart = contentState.getSelectionBefore().getAnchorOffset();
        let selEnd = contentState.getSelectionBefore().getFocusOffset();

        // when a link is clicked we have to adjust the selectionState so
        // the link's text is selected before we toggle the link or else
        // a draftjs entity will stick around and cause havoc
        if (selectionState.getAnchorOffset() == selectionState.getFocusOffset() && selStart !== selEnd) {
            let updatedSelection = selectionState.merge({
                anchorOffset: selStart,
                focusOffset: selEnd
            })

            this.setState({
                editorState: RichUtils.toggleLink(editorState, updatedSelection, null)
            });

        } else {
            this.setState({
                editorState: RichUtils.toggleLink(editorState, selectionState, null)
            });
        }

        this.editingReferenceCounter -= 1;
        if (this.editingReferenceCounter <= 0) {
            this.setState({isComponentEditing: false});
        }
        setTimeout(() => this.refs.editor.focus(), 0)
    }

    /**
     *
     */
    remove() {
        const {editorState} = this.state;
        let selectionState = editorState.getSelection();

        let contentState = editorState.getCurrentContent();
        let selStart = contentState.getSelectionBefore().getAnchorOffset();
        let selEnd = contentState.getSelectionBefore().getFocusOffset();

        // when a link is clicked we have to adjust the selectionState so
        // the link's text is selected before we toggle the link or else
        // a draftjs entity will stick around and cause havoc
        if (selectionState.getAnchorOffset() == selectionState.getFocusOffset() && selStart !== selEnd) {
            let updatedSelection = selectionState.merge({
                anchorOffset: selStart,
                focusOffset: selEnd
            })

            this.setState({
                editorState: RichUtils.toggleLink(editorState, updatedSelection, null)
            });

        } else {
            this.setState({
                editorState: RichUtils.toggleLink(editorState, selectionState, null)
            });
        }

        this.editingReferenceCounter -= 1;
        if (this.editingReferenceCounter <= 0) {
            this.setState({isComponentEditing: false});
        }
        setTimeout(() => this.refs.editor.focus(), 0)
    }

    /**
     * Custom block components must call this to release the edit state lock and call
     * the state UNDO to remove their injected container of the action is not completed.
     * Uses reference counting to ensure that locks are not prematurely released
     */
    releaseCancel() {
        this.editingReferenceCounter -= 1;
        if (this.editingReferenceCounter <= 0) {
            this.setState({isComponentEditing: false});
        }

        const {editorState} = this.state;
        this.onChange(EditorState.undo(editorState));

        setTimeout(() => this.refs.editor.focus(), 0)
    }

    /**
     * Block render function decides how to render custom blocks. Currently all custom blocks
     * must have a 'customType' meta property to allow the system to use the right component
     *
     * @param  {ContentBlock} block
     * @return {object}       custom block info
     */
    blockRender(block) {
        if (block.getType() !== 'atomic') {
            return null;
        }
        const {editorState} = this.state;
        // console.log(Entity.get(block.getEntityAt(0)).getData());

        if (editorState.getCurrentContent().getEntity(block.getEntityAt(0)).getData().customType === 'pull-quote') {
            return {
                component: PullQuoteBlock,
                props: {
                    onStartEdit: this.retain.bind(this),
                    onFinishEdit: this.release.bind(this)
                },
                editable: false
            };
        } else if (editorState.getCurrentContent().getEntity(block.getEntityAt(0)).getData().customType === 'horizontal-rule') {
            return {
                component: HorizontalRuleBlock,
                props: {
                    onStartEdit: this.retain.bind(this),
                    onFinishEdit: this.release.bind(this)
                },
                editable: false
            };
        } else if (editorState.getCurrentContent().getEntity(block.getEntityAt(0)).getData().customType === 'embed') {
            return {
                component: EmbedBlock,
                props: {
                    onStartEdit: this.retain.bind(this),
                    onFinishEdit: this.release.bind(this),
                    // editorState: this.state.editorState
                },
                editable: false
            }
        } else if (editorState.getCurrentContent().getEntity(block.getEntityAt(0)).getData().customType === 'media') {
            return {
                component: MediaBlock,
                props: {
                    onStartEdit: this.retain.bind(this),
                    onFinishEdit: this.release.bind(this),
                    onCancelEdit: this.releaseCancel.bind(this)
                },
                editable: false
            }
        }

        return null;

    }

    /**
     * Inject a pull quote block
     */
    addPullQuote() {
        const {editorState} = this.state;
        const selectionState = editorState.getSelection();

        let quoteText = null;

        if (selectionState && !selectionState.isCollapsed()) {
            const block = editorState.getCurrentContent().getBlockForKey(selectionState.getFocusKey());
            quoteText = block.getText().slice(
                selectionState.getStartOffset(),
                selectionState.getEndOffset()
            );
        }


        this.onChange(insertPullQuote(editorState, quoteText));
    }

    /**
     * Inject horizontal rule
     */
    addHorizontalRule() {
        const {editorState} = this.state;

        this.onChange(insertHorizontalRule(editorState));
    }

    /**
     * Inject embed block
     */
    addEmbed() {
        const {editorState} = this.state;

        this.onChange(insertEmbed(editorState));
    }

    /**
     * Inject media block
     */
    addMedia() {
        const {editorState} = this.state;
        this.onChange(insertMedia(editorState));
    }

    /**
     * WillMount handles loading the proper data into the editor. IF the default value is a string,
     * loads as HTML string, if it's an object, loads as raw data. Otherwise creates a empty editor.
     */
    componentWillMount() {
        if (this.props.defaultValue && (typeof this.props.defaultValue === 'string' || this.props.defaultValue instanceof String)) {

            // If the passed string is actually an encoded JSON object, recreate the state
            // of the editor from that
            if (this.props.defaultValue.indexOf('{') === 0) {
                let contentState;
                if (this.props.defaultValue.indexOf('\\"') !== -1) {
                    contentState = convertFromRaw(JSON.parse(reduceSlashes(this.props.defaultValue)));
                } else {
                    contentState = convertFromRaw(JSON.parse(this.props.defaultValue));
                }

                this.state = {editorState: EditorState.createWithContent(contentState, this.decorator)};
            } else if (this.props.defaultValue.indexOf('<p') !== -1) {
                // It's html
                const contentState = stateFromHTML(this.props.defaultValue);
                this.state = {editorState: EditorState.createWithContent(contentState, this.decorator)};
            } else {
                // Otherwise just treat it as a string
                const contentState = ContentState.createFromText(this.props.defaultValue);

                this.state = {editorState: EditorState.createWithContent(contentState, this.decorator)};
            }

            return;
        } else if (this.props.unlinkedValue) {

            // If the passed string is actually an encoded JSON object, recreate the state
            // of the editor from that
            if (this.props.unlinkedValue.indexOf('{') === 0) {
                const contentState = convertFromRaw(JSON.parse(this.props.unlinkedValue));

                this.state = {editorState: EditorState.createWithContent(contentState, this.decorator)};
            } else if (this.props.unlinkedValue.indexOf('<p') !== -1) {
                // It's html
                const contentState = stateFromHTML(this.props.unlinkedValue);
                this.state = {editorState: EditorState.createWithContent(contentState, this.decorator)};
            } else {
                // Otherwise just treat it as a string
                const contentState = ContentState.createFromText(this.props.unlinkedValue);

                this.state = {editorState: EditorState.createWithContent(contentState, this.decorator)};
            }

            this.state.defaultValue = this.props.unlinkedValue;

            return;
        }

        this.state = {editorState: EditorState.createEmpty(this.decorator), doShowUrlInput: false, doShowNoteInput: false};
    }

    /**
     * Called on each editor update (There are a lot of them).
     * Also allows you to attach an onchange event to the parent component to track the raw state
     *
     * This is useful for autosaves
     * @param  {EditorState} editorState
     */
    onChange(editorState) {
        // This just pases the editor state, and content state back to the parent component. It's usually used
        // for autosave
        if (this.props.onChange) {
            this.props.onChange(convertToRaw(editorState.getCurrentContent()), editorState, this.props.name);
        }
        return this.setState({editorState});
    }

    /**
     * Watches for updates to ensure that copy notes are properly selected on mouse enter,
     * also emits an update event that can be tracked by other components
     * @param  {EditorState} prevProps
     * @param  {EditorState} prevState
     */
    componentDidUpdate(prevProps, prevState) {

        // Emit a change event for the watchers
        if (!this.props.name) {
            console.trace();
            return;
        }
        Events.emit('draftEditorDidUpdate', this.props.name);
        const {editorState} = this.state;
        const selection = editorState.getSelection();
        if (selection) {

            // The caret is in a block, check the entity
            const block = editorState.getCurrentContent().getBlockForKey(selection.getFocusKey());
            const key = block.getEntityAt(selection.getAnchorOffset())

            if (key) {
                try {
                    // The caret is in an entry, it could really be any inline block.
                    // check to see if it's a note then update the last selected note
                    // in the NotesStore so it can update properly
                    if (!editorState.getCurrentContent().getEntity(key).getData().noteUuid) {
                        return;
                    }
                    CopyNoteService.setLastSelectedNote(editorState.getCurrentContent().getEntity(key).getData().noteUuid);
                    return;
                } catch (e) {
                }

            }
        }

        CopyNoteService.setLastSelectedNote(false);

    }

    /**
     * Map keys to events. Currently tracked
     * MOD is CMD on Mac and WIN on Windows
     * <ul>
     *     <li>MOD+CTL+1 - H1</li>
     *     <li>MOD+CTL+2 - H2</li>
     *     <li>MOD+CTL+3 - H3</li>
     *     <li>MOD+CTL+4 - H4</li>
     *     <li>MOD+CTL+5 - H5</li>
     *     <li>MOD+CTL+6 - H6</li>
     *     <li>MOD+CTL+A - Copy note</li>
     *     <li>MOD+K - Insert link</li>
     *     <li>MOD+D - Insert CQ</li>
     * </ul>
     * @param  {event} e
     */
    keyBinding(e) {
        // CMD modifier pressed
        // not using built-in because it confuses meta/alt/ctl commands
        let isMeta = false;
        let isAlt = false;
        const isMac = (navigator.userAgent.search(/(Mac OS X|MacPPC|MacIntel|Mac_PowerPC|Macintosh)/ig) !== -1) ? true : false;

        if (isMac && e.metaKey) {
            isMeta = true;
        } else if (!isMac && e.ctrlKey) {
            isMeta = true;
        }
        isAlt = e.altKey;

        if (isMeta) {
            // console.log(e.target.altKey, e.altKey);
            // AND alt
            if (isAlt) {
                switch(e.keyCode) {
                    case 49:
                        return 'header-one';
                        break;
                    case 50:
                        return 'header-two';
                        break;
                    case 51:
                        return 'header-three';
                        break;
                    case 52:
                        return 'header-four';
                        break;
                    case 53:
                        return 'header-five';
                        break;
                    case 54:
                        return 'header-six';
                        break;
                    case 65:
                        return 'richeditor-note';
                        break;
                }
            }

            // JUST CMD
            switch (e.keyCode) {
                case 75: /* `K` key */
                    return 'richeditor-link';
                    break;

                case 68: /* `D` key */
                    return 'richeditor-copyquote';
                    break;
            }
        }

        return getDefaultKeyBinding(e);
    }

    _handleKeyCommand(command) {
        const {editorState} = this.state;

        // Does the actual opening of modal when user hits CMD+K
        switch (command) {
            case 'richeditor-copyquote':
                this.addCopyQuote();
                return true;
                break;
            case 'richeditor-link':
                this.promptForLink();
                return true;
                break;
            case 'richeditor-note':
                this.promptForNote();
                return true;
                break;
        }

        if (command.indexOf('header-') === 0) {
            this.toggleBlockType(command);
            return true;
        }

        const newState = RichUtils.handleKeyCommand(editorState, command);

        if (newState) {
            this.onChange(newState);
            return true;
        }
        return false;
    }

    _handleBeforeInput(str, editorState) {
        if (!this.props.application.settings.get('educateQuotes')) {
            return false;
        }

        if (str == "'" || str == '"') {
            const selectionState = editorState.getSelection();
            const currentBlock = editorState
                .getCurrentContent()
                .getBlockForKey(editorState.getSelection().getStartKey());

            const start = selectionState.getStartOffset();
            let end = start;
            const text = currentBlock.getText();

            let quote = '';

            if (str == "'") {
                quote = '’';
                if (!start || start && !/(\b|[\.\!\?])/.test(text.charAt(start-1))) {
                    quote = '‘';
                }
            } else {
                quote = '”';
                if (!start || start && !/(\b|[\.\!\?])/.test(text.charAt(start-1))) {
                    quote = '“';
                }
            }

            const replacement = Modifier.replaceText(
                editorState.getCurrentContent(),
                selectionState,
                quote
            );
            this.onChange(EditorState.push(editorState, replacement, 'replace-characters'));
            return true;
        }

        return false;
    }

    /**
     * Wrap selected text with CQ marker
     */
    addCopyQuote() {
        const {editorState} = this.state;
        if (editorState.getSelection().isCollapsed()) {
            alert('You need to select some text.');
            return;
        }

        const newContentState = editorState.getCurrentContent().createEntity(
            'COPYQUOTE',
            'IMMUTABLE',
            {
                user: CurrentUser.getName(),
                version: this.props.version,
                created_at: moment().utc().format('YYYY-MM-DD HH:mm:ss')
            }
        );
        const entityKey = newContentState.getLastCreatedEntityKey();

        let newSelectionState;
        if (this.state.editorState.getSelection().getIsBackward()) {
            newSelectionState = this.state.editorState.getSelection().merge({
                focusOffset: this.state.editorState.getSelection().getAnchorOffset()
            });
        } else {
            newSelectionState = this.state.editorState.getSelection().merge({
                anchorOffset: this.state.editorState.getSelection().getFocusOffset()
            });
        }

        this.onChange(
            EditorState.forceSelection(
                RichUtils.toggleLink(
                    editorState,
                    editorState.getSelection(),
                    entityKey
                ),
                newSelectionState
            )
        );
    }

    _toggleBlockType(blockType) {
        const {editorState} = this.state;
        this.onChange(
            RichUtils.toggleBlockType(
                editorState,
                blockType
            )
        );
    }

    _toggleInlineStyle(inlineStyle) {
        this.onChange(
            RichUtils.toggleInlineStyle(
                this.state.editorState,
                inlineStyle
            )
        );
    }

    toggleAlignment(align) {
        console.log('SET', align);
        this.onChange(
            ExtendedRichUtils.toggleAlignment(
                this.state.editorState,
                align
            )
        );
    }

    /**
     * Open note modal
     * @param  {Event} e
     */
    promptForNote(e) {
        const {editorState} = this.state;
        const selection = editorState.getSelection();
        if (!selection.isCollapsed()) {

            if (selection.getStartKey()) {
                const block = editorState.getCurrentContent().getBlockForKey(selection.getFocusKey());
                const key = block.getEntityAt(selection.getAnchorOffset())
                if (key && editorState.getCurrentContent().getEntity(key)) {
                    return;
                }
            }

            const id = uuid.v4();

            // We use the unique id here to track the modal in the root container
            this.setState({
                doShowNoteInput: id
            });
        } else {
            try {
                this.onChange(expandSelection(editorState));
            } catch (except) {
                console.log(except);
                alert('No selectable text found!');
                return;
            }
            setTimeout(() => {
                this.promptForNote(e);
            }, 10);
        }
    }

    /**
     * Wrap text in link edit inline block
     * @param  {event} e
     */
    promptForLink(e) {
        const {editorState} = this.state;
        const selection = editorState.getSelection();

        if (editorState.getSelection().isCollapsed()) {
            alert('You need to select some text.');
            return;
        }

        const contentState = editorState.getCurrentContent();
        let linkOffset = null;

        if(selection.getStartKey() == selection.getEndKey()) {
            let block = contentState.getBlockForKey(selection.getStartKey());
            linkOffset = block.getEntityAt(selection.getStartOffset());
        }

        if (!selection.isCollapsed() && !linkOffset) {
            this.onChange(insertLinkEntity(editorState));
        } else if (!selection.isCollapsed()) {
            this.setState({
                editorState: RichUtils.toggleLink(editorState, selection, null)
            });
        }
    }

    /**
     * Clear the link style from selected text. You must select the entire
     * link text to remove the link
     * @param  {event} e
     */
    removeLink(e) {
        const {editorState} = this.state;
        const selection = editorState.getSelection();
        if (!selection.isCollapsed()) {
            this.onChange(removeLinkEntity(editorState, selection));
        }
    }

    /**
     * Toggle text in dropcap range
     * @param  {event} e
     */
    toggleDropCap(e) {
        const {editorState} = this.state;
        const selection = editorState.getSelection();
        if (!selection.isCollapsed()) {
            this.onChange(toggleDropCapEntity(editorState));
        }
    }

    /**
     * If modal is closed, returns focus to editor
     * @param  {EditorState} state
     */
    onNoteModalUpdate(state) {
        if (this.state.doShowNoteInput !== state.doShowNoteInput && state.doShowNoteInput === false) {
            this.setState({doShowNoteInput: false});
        }

        this.setState(
            state,
            () => {setTimeout(() => this.refs.editor.focus(), 0);}
        );
    }

    handleReturn(e) {
        if (e && e.shiftKey) {
            const {editorState} = this.state;
            this.onChange(
                RichUtils.insertSoftNewline(
                    editorState
                )
            );
            return true;
        }
    }

    render() {
        const {editorState} = this.state;

        // If the user changes block type before entering any text, we can
        // either style the placeholder or hide it. Let's just hide it now.
        let className = 'richEditor-editor';
        var contentState = editorState.getCurrentContent();
        if (!contentState.hasText()) {
            if (contentState.getBlockMap().first().getType() !== 'unstyled') {
                className += ' richEditor-hidePlaceholder';
            }
        }

        var currentStyle = editorState.getCurrentInlineStyle();

        const selection = editorState.getSelection();
        const blockType = editorState
            .getCurrentContent()
            .getBlockForKey(selection.getStartKey())
            .getType();

        let styles = {};
        if (window && window.innerHeight) {
            styles = {
                maxHeight: (window.innerHeight - 200) + 'px',
                overflow: 'auto'
            };
        }

        return (
            <div>
                {this.props.label ? <Subheader style={{marginTop:'-16px',paddingLeft:'0px'}}>{this.props.label}</Subheader> : '' }
                <div className="richEditor-root">
                    <div className='row richEditor-toolbar middle-xs'>
                        <div className='col-xs-12'>
                            <div className='box'>
                                <div className='richEditor-controls' style={{borderRight:'1px solid  #666', paddingRight:'6px', marginRight:'12px'}}>
                                    <StyleButton
                                        key='Link'
                                        label='Link (CMD+K)'
                                        icon='insert_link'
                                        onToggle={this.promptForLink}
                                        />
                                    <StyleButton
                                        key="Media"
                                        label="Embed Media"
                                        icon="perm_media"
                                        onToggle={this.addMedia}
                                        />
                                    <StyleButton
                                        key="Embed"
                                        label="Embed Code"
                                        icon="code"
                                        onToggle={this.addEmbed}
                                        />
                                    <StyleButton
                                        key="cq"
                                        label="CQ (CMD+D)"
                                        icon="check"
                                        onToggle={this.addCopyQuote}
                                        />
                                    {
                                        this.props.enableComments === true
                                        ? (
                                            <StyleButton
                                                key='Note'
                                                label='Note (CMD+ALT+A)'
                                                icon='comment'
                                                onToggle={this.promptForNote}
                                                />
                                        )
                                        : ''
                                    }
                                </div>

                                <FormatStyleControls
                                    editorState={editorState}
                                    onToggle={this.toggleBlockType}
                                />

                                <InlineStyleControls
                                    editorState={editorState}
                                    onToggle={this.toggleInlineStyle}
                                    >
                                    <StyleButton
                                        key='DropCap'
                                        label='Drop Cap'
                                        icon='format_size'
                                        onToggle={this.toggleDropCap}
                                        />
                                </InlineStyleControls>

                                <BlockStyleControls
                                    editorState={editorState}
                                    onToggle={this.toggleBlockType}
                                    >
                                    <StyleButton
                                        key="LeftAlign"
                                        label="Left Align"
                                        icon="format_align_left"
                                        onToggle={this.toggleAlignment.bind(this, 'LEFT')}
                                        />
                                    <StyleButton
                                        key="CenterAlign"
                                        label="Center Align"
                                        icon="format_align_center"
                                        onToggle={this.toggleAlignment.bind(this, 'CENTER')}
                                        />
                                    <StyleButton
                                        key="RightAlign"
                                        label="Right Align"
                                        icon="format_align_right"
                                        onToggle={this.toggleAlignment.bind(this, 'RIGHT')}
                                        />
                                    <StyleButton
                                        key="PullQuote"
                                        label="Pull Quote"
                                        icon="format_quote"
                                        onToggle={this.addPullQuote}
                                        />
                                    <StyleButton
                                        key="HoriRule"
                                        label="Horizontal Rule"
                                        icon="border_horizontal"
                                        onToggle={this.addHorizontalRule}
                                        />
                                </BlockStyleControls>
                            </div>
                        </div>
                        <div className='col-xs-12 clear-top'>
                            <div className='box'>
                                {this.props.toolbarRightElement}
                            </div>
                        </div>
                    </div>
                    <div className={className} onClick={this.focus} style={styles}>
                        <Editor
                            blockStyleFn={getBlockStyle}
                            blockRendererFn={this.blockRender}
                            customStyleMap={styleMap}
                            editorState={editorState}
                            handleKeyCommand={this.handleKeyCommand}
                            handleBeforeInput={this.handleBeforeInput}
                            handleReturn={this.handleReturn.bind(this)}
                            keyBindingFn={this.keyBinding}
                            onChange={this.onChange}
                            placeholder={this.props.placeholder}
                            ref="editor"
                            spellCheck={true}
                            htmlFor={this.props.name}
                            readOnly={(this.state.isComponentEditing || this.props.readOnly) ? true : false}
                        />
                    </div>
                    <LinkEditor open={this.state.isLinkEditorOpen} editorState={this.state.editorState} entityKey={this.state.linkEntity} onRequestClose={this.linkEndEdit.bind(this)} />
                    <div className='small-info'>
                        Auto-save Drafts <strong>on</strong> | Smart Quotes <strong>{this.props.application.settings.get('educateQuotes') ? 'on' : 'off'}</strong>
                    </div>
                </div>
                {
                    this.state.doShowNoteInput
                    ? <NoteModal key={this.state.doShowNoteInput} contentUuid={this.props.contentUuid} editorName={this.props.name} editorState={this.state.editorState} version={this.props.version} onUpdate={this.onNoteModalUpdate.bind(this)} />
                    : ''
                }
            </div>
        );
    }
}

// Custom overrides for "code" style.
const styleMap = {
    CODE: {
        backgroundColor: 'rgba(0, 0, 0, 0.05)',
        fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace',
        fontSize: 16,
        padding: 2,
    },
};

/**
 * Custom block styles.
 * @param  {ContentBlock} block
 */
function getBlockStyle(block) {
    const textAlignStyle = block.getData().get(ALIGNMENT_DATA_KEY);
    if (textAlignStyle) {

        switch (textAlignStyle) {
            case 'RIGHT':
                return 'align-right';
            case 'CENTER':
                return 'align-center';
            case 'LEFT':
                return 'align-left';
            case 'JUSTIFY':
                return 'align-justify';
        }
    }

    switch (block.getType()) {
        case 'blockquote': return 'richEditor-blockquote';
        default: return null;
    }
}

/**
 * Component to build the editor buttons.
 */
class StyleButton extends React.Component {
    constructor() {
        super();
        this.onToggle = (e) => {
            e.preventDefault();
            this.props.onToggle(this.props.style);
        };
    }

    render() {
        let className = 'richEditor-styleButton';
        if (this.props.active) {
            className += ' richEditor-activeButton';
        }

        let content;

        if (this.props.icon) {
            const iconClass = this.props.icon;
            if (iconClass.indexOf('hicon') !== -1) {
                content = (<FontIcon className={iconClass} style={{'fontSize': '0.6rem', 'top': '-4px'}}></FontIcon>);
            } else if (iconClass.indexOf('fa-') !== -1) {
                content = (<FontIcon className={iconClass} style={{'fontSize': '0.75rem', 'top': '-2px'}}></FontIcon>);
            } else {
                content = (<FontIcon className='mui-icons' style={{'fontSize': '1rem'}}>{iconClass}</FontIcon>);
            }
        } else {
            content = this.props.label;
        }

        return (
            <span
                className={className}
                title={this.props.label}
                onMouseDown={this.onToggle}
                >
                {content}
            </span>
        );
    }
}

const BLOCK_TYPES = [
    {label: 'UL', style: 'unordered-list-item', icon: 'format_list_bulleted'},
    {label: 'OL', style: 'ordered-list-item', icon: 'format_list_numbered'},
    {label: 'Blockquote', style: 'blockquote', icon: 'format_indent_increase'},
    // {label: 'Code Block', style: 'code-block', icon: 'code'},
];

/**
 * Render block styling buttons
 */
const BlockStyleControls = (props) => {
    const {editorState} = props;
    const selection = editorState.getSelection();
    const blockType = editorState
        .getCurrentContent()
        .getBlockForKey(selection.getStartKey())
        .getType();

    return (
        <div className="richEditor-controls">
            {BLOCK_TYPES.map((type) =>
                <StyleButton
                    key={type.label}
                    active={type.style === blockType}
                    label={type.label}
                    icon={type.icon}
                    onToggle={props.onToggle}
                    style={type.style}
                />
            )}
            {props.children}
        </div>
    );
};

const FORMAT_TYPES = [
    {label: 'Normal', style: 'unstyled', icon: 'hicon-normal'},
    {label: 'H1', style: 'header-one', icon: 'hicon-h1'},
    {label: 'H2', style: 'header-two', icon: 'hicon-h2'},
    {label: 'H3', style: 'header-three', icon: 'hicon-h3'},
    {label: 'H4', style: 'header-four', icon: 'hicon-h4'},
    {label: 'H5', style: 'header-five', icon: 'hicon-h5'},
    {label: 'H6', style: 'header-six', icon: 'hicon-h6'},
];

/**
 * Render heading/graf formatting dropdown
 */
const FormatStyleControls = (props) => {
    const {editorState} = props;
    const selection = editorState.getSelection();
    const blockType = editorState
        .getCurrentContent()
        .getBlockForKey(selection.getStartKey())
        .getType();

    return (
        <div className="richEditor-controls">
            {FORMAT_TYPES.map((type) =>
                <StyleButton
                    key={type.label}
                    active={type.style === blockType}
                    label={type.label}
                    icon={type.icon}
                    onToggle={props.onToggle}
                    style={type.style}
                />
            )}
            {props.children}
        </div>
    );
};

var INLINE_STYLES = [
    {label: 'Bold', style: 'BOLD', icon: 'format_bold'},
    {label: 'Italic', style: 'ITALIC', icon: 'format_italic'},
    {label: 'Underline', style: 'UNDERLINE', icon: 'format_underlined'},
    // {label: 'Monospace', style: 'CODE', icon: 'check_box_outline_blank'},
];

/**
 * Render inline styles
 */
const InlineStyleControls = (props) => {
    var currentStyle = props.editorState.getCurrentInlineStyle();
    return (
        <div className="richEditor-controls">
            {INLINE_STYLES.map(type =>
                <StyleButton
                    key={type.label}
                    active={currentStyle.has(type.style)}
                    label={type.label}
                    icon={type.icon}
                    onToggle={props.onToggle}
                    style={type.style}
                />
            )}
            {props.children}
        </div>
    );
};

/**
 * Locates and marks copy note inline styles.
 */
function findNoteEntities(contentBlock, callback, contentState) {
    contentBlock.findEntityRanges(
        (character) => {
            const entityKey = character.getEntity();
            return (
                entityKey !== null &&
                contentState.getEntity(entityKey).getType() === 'COPYNOTE'
            );
        },
        callback
    );
}

/**
 * Copy note INLINE style. This is not the note itself, that's in the NoteViewer component
 */
class CopyNote extends React.Component {
    constructor(props) {
        super(props);

        this.state = {id: uuid.v4()};
    }

    componentDidMount() {
        const {editorName, noteUuid, noteContent, userName, userIcon, userEmail, version, created_at} = this.props.contentState.getEntity(this.props.entityKey).getData();

        CopyNoteService.add(this.state.id, noteUuid, noteContent, userName, userIcon, userEmail, version, created_at);
    }

    componentWillUnmount() {
        const {editorName, noteUuid, noteContent} = this.props.contentState.getEntity(this.props.entityKey).getData();

        CopyNoteService.remove(this.state.id);
    }

    componentDidUpdate(prevProps, prevState) {
        const {editorName, noteUuid, noteContent} = this.props.contentState.getEntity(this.props.entityKey).getData();

        CopyNoteService.update(this.state.id, noteUuid, noteContent);
    }

    render() {
        const {editorName, noteUuid, noteContent} = this.props.contentState.getEntity(this.props.entityKey).getData();

        return (
            <span className={'copyNoteMarker note-' + noteUuid} ref={'note-' + noteUuid} data-editorName={editorName} htmlFor={'note-' + noteUuid}>
                {this.props.children}
            </span>
        );
    }
};

/**
 * Render note entry modal. Again the actual note lives in the NoteViewer component
 */
class NoteModal extends React.Component {
    constructor(props) {
        super(props);
        this.displayName = 'NoteModal';
        this.confirm = this.confirm.bind(this);
        this.cancel = this.cancel.bind(this);
        this.onLinkInputKeyDown = this.onLinkInputKeyDown.bind(this);

        this.state = {offsetTop: 0};
    }

    componentWillMount() {
        this.state.editorState = this.props.editorState;
        this.state.noteValue = '';
        this.state.noteUser = '';

        const {editorState} = this.state;
        const selection = editorState.getSelection();

        if (selection && selection.getStartKey()) {
            const block = editorState.getCurrentContent().getBlockForKey(selection.getFocusKey());
            const key = block.getEntityAt(selection.getAnchorOffset())
            try {
                this.state.noteValue = this.state.editorState.getCurrentContent().getEntity(key).getData().noteContent;
                this.state.noteUuid = this.state.editorState.getCurrentContent().getEntity(key).getData().noteUuid;
            } catch (e) {
                this.state.noteValue = '';
                this.state.noteUuid = uuid.v4();
            }
        }

        const el = getSelectedBlockElement();
        let offset = {top: 0, left: 0, height: 100, width: 100};
        if (window && window.jQuery) {
            offset = jQuery(el).offset();
        }

        this.state.offset = offset;
    }

    /**
     * Watch for CMD+Return to save, and ESC to close
     */
    onLinkInputKeyDown(e) {
        if (e.which === 27) {
            this.cancel(e);
            return;
        }

        if (e.which === 13 && e.metaKey) {
            this.confirm(e);
        }
    }

    componentDidMount() {
        setTimeout(() => this.refs.note.focus(), 0);
    }

    confirm(e) {
        e.preventDefault();

        const {noteValue, editorState} = this.state;
        const newContentState = editorState.getCurrentContent().createEntity(
            'COPYNOTE',
            'MUTABLE',
            {
                editorName: this.props.editorName,
                noteContent: this.state.noteValue,
                noteUuid: this.state.noteUuid,
                userName: CurrentUser.getName(),
                userIcon: CurrentUser.getGravatar(),
                userEmail: CurrentUser.getEmail(),
                version: this.props.version,
                created_at: moment().utc().format('YYYY-MM-DD HH:mm:ss')
            }
        );
        const entityKey = newContentState.getLastCreatedEntityKey();

        this.props.onUpdate({
            editorState: RichUtils.toggleLink(
                editorState,
                editorState.getSelection(),
                entityKey
            ),
            doShowNoteInput: false,
            noteValue: '',
        });

        // handle mentions
        const mentions = this.state.noteValue.match(/\@[\w]+/g);
        // we hijack the noteComment service to create a placeholder note
        // for the mention
        const service = new NoteCommentFlux();
        if (mentions.length) {
            const data = {
                comment: this.state.noteValue,
                version: this.props.version,
                draftUuid: this.state.noteUuid,
                contentUuid: this.props.contentUuid ? this.props.contentUuid : false
            };
            service.actions.noteComments.create('root', data);
        }
    }

    cancel(e) {
        e.preventDefault();
        const {editorState} = this.state;

        this.props.onUpdate({
            doShowNoteInput: false,
            noteValue: ''
        });
    }

    handleEditorChange(raw, state, name) {
        this.setState({noteValue: stateToHTML(state.getCurrentContent())});
    }

    render() {
        const {editorState} = this.state;
        let note = this.state.noteValue ? this.state.noteValue : '';

        const actions = [
            <FlatButton className='close' onClick={this.cancel} label='Cancel' />,
            <FlatButton onClick={this.confirm} label="Save Note" />
        ];

        return (
            <Dialog
                title="New Comment"
                actions={actions}
                modal={true}
                open={true}
                repositionOnUpdate={true}
                >
                <SimpleEditor style={{'maxHeight':(window.innerHeight - 400) + 'px', 'overflow':'auto'}} showToolbar={false} onChange={this.handleEditorChange.bind(this)} ref='note'/>
            </Dialog>
        );
    }
}

/**
 * Locate and style CQ entities
 */
function findCopyQuoteEntities(contentBlock, callback, contentState) {
    contentBlock.findEntityRanges(
        (character) => {
            const entityKey = character.getEntity();
            return (
                entityKey !== null &&
                contentState.getEntity(entityKey).getType() === 'COPYQUOTE'
            );
        },
        callback
    );
}

/**
 * Simple CQ styler
 */
const CopyQuote = (props) => {
    const {user, version, created_at} = props.contentState.getEntity(props.entityKey).getData();
    return (
        <span title={user + ', ' + timeToFormat(created_at, 'M/DD hh:mma') + ' @ version ' + version} className="richEditor-copyQuote">
            {props.children}
        </span>
    );
};

/**
 * Finds the RENDERED block component that contains the current selection.
 * This should only be used for positioning and not for data management
 * @return {Element}
 */
function getSelectedBlockElement() {
    const selection = window.getSelection();
    if (selection.rangeCount == 0) {
        return null;
    }
    let node = selection.getRangeAt(0).startContainer;
    do {
        if (node.getAttribute && node.getAttribute('data-block') == 'true') {
            return node;
        }
        node = node.parentNode;
    } while (node != null);

    return null;
}

const mapStateToProps = (state) => {
    return {
        application: state.application
    };
}

export default connect(mapStateToProps)(RichEditor);