Home Reference Source

application/components/common/rich-editor2/index.js

import React from 'react';
import {Editor, getEventTransfer} from 'slate-react';
import {Value} from 'slate';
import Plain from 'slate-plain-serializer';
import Html from 'slate-html-serializer';
import IconMenu from 'material-ui/IconMenu';
import MenuItem from 'material-ui/MenuItem';
import IconButton from './util/icon-button';
import _ from 'lodash';
import ToolbarButton from './common/toolbar-button';
import ToolbarSeparator from './common/toolbar-separator';
import Config from '../../../config';

import {isPrimaryPlus, isSecondaryPlus} from './util/modifiers';

import SoftBreaks from './plugins/soft-breaks';
import SmartQuotes from './plugins/smart-quotes';
import {AnchorPlugin, AnchorButton, SERIALIZER_RULES as ANCHOR_RULES} from './plugins/anchor';
import {StylerPlugin, StylerButton, SERIALIZER_RULES as STYLER_RULES} from './plugins/styler';
import {CqPlugin, CqButton, SERIALIZER_RULES as CQ_RULES} from './plugins/cq';
import {GridPlugin, gridClickHandler, gridAddCellHandler, gridRemoveCellHandler, SERIALIZER_RULES as GRID_RULES} from './plugins/grid';
import {TablePlugin, tableClickHandler, tableAddRow, tableAddColumn, tableRemoveRow, tableRemoveColumn, SERIALIZER_RULES as TABLE_RULES} from './plugins/table';
import {NotePlugin, NoteButton, SERIALIZER_RULES as NOTE_RULES} from './plugins/note';
import {EmbedPlugin, EmbedButton, SERIALIZER_RULES as EMBED_RULES, embedHasCursor} from './plugins/embed';
import {MediaPlugin, MediaButton, SERIALIZER_RULES as MEDIA_RULES} from './plugins/media';
import {HrPlugin, HrButton, SERIALIZER_RULES as HR_RULES} from './plugins/hr';
import {SuggestionPlugin, SuggestionButton} from './plugins/suggestion';

import {ensureEmptyGraf} from './util/ensure-empty-graf';

import {RULES, combineRules} from './util/serializer';

import {defaultSchema} from './schema';

import Draft from './util/draft-serializer';

const SERIALIZER_RULES = combineRules([
    RULES,
    ANCHOR_RULES,
    STYLER_RULES,
    CQ_RULES,
    GRID_RULES,
    TABLE_RULES,
    NOTE_RULES,
    EMBED_RULES,
    MEDIA_RULES,
    HR_RULES
]);

function MarkHotKey(options) {
    const {type, key} = options;

    return {
        onKeyDown(e, editor, next) {
            if (!isPrimaryPlus(e, key)) {
                return next();
            }

            e.preventDefault();
            editor.toggleMark(type);
        }
    }
}

function NodeHotKey(options) {
    const {type, key} = options;

    return {
        onKeyDown(e, editor, next) {
            if (!isPrimaryPlus(e, key)) {
                return next();
            }
            const isActive = editor.value.blocks.some(block => block.type == type);
            e.preventDefault();
            editor.setBlocks(isActive ? 'paragraph' : type);
        }
    }
}

function CodeNode(props) {
    return (
        <pre {...props.attributes} className='code-node'>
            <code>{props.children}</code>
        </pre>
    )
}

export const Serializer = new Html({rules: SERIALIZER_RULES});

export default class RichEditor2 extends React.Component
{
    constructor(props) {
        super(props);

        this.editor = null;

        let initialValue = Plain.deserialize('');

        if (this.props.initialValue && this.props.initialValue.indexOf('"object":"value"') !== -1) {
            initialValue = Value.fromJSON(JSON.parse(this.props.initialValue));
        } else if (this.props.initialValue && this.props.initialValue.indexOf('entityMap') !== -1) {
            initialValue = Draft.deserialize(JSON.parse(this.props.initialValue));
        } else if (this.props.initialValue && this.props.initialValue.search(/\<p/i) !== -1) {
            initialValue = Serializer.deserialize(this.props.initialValue);
        } else {
            initialValue = Plain.deserialize(this.props.initialValue ? this.props.initialValue : '');
        }

        this.state = {
            // 'editorContent': Value.fromJSON({})
            'editorContent': initialValue,
            'shiftDown': false
        };

        let smartQuotes = false;
        if (this.props.smartQuotes) {
            smartQuotes = this.props.smartQuotes ? true : false;
        }

        this.plugins = [
            SoftBreaks({
                shift: true,
                inBlocks: ['code', 'embed']
            }),
            SmartQuotes({
                enabled: smartQuotes,
                inBlocks: ['div', 'paragraph']
            }),
            SuggestionPlugin({
                keywords: this.props.keywords || null,
                related: this.props.related || null
            }),
            GridPlugin(),
            TablePlugin(),
            AnchorPlugin(),
            StylerPlugin(),
            CqPlugin(),
            NotePlugin(),
            EmbedPlugin(),
            MediaPlugin(),
            HrPlugin(),
            MarkHotKey({'type': 'bold', 'key': 'b'}),
            MarkHotKey({'type': 'italic', 'key': 'i'}),
            MarkHotKey({'type': 'underline', 'key': 'u'}),
        ];

        this.serializer = Serializer;


        this.hasBlock = this.hasBlock.bind(this);
        this.hasInline = this.hasInline.bind(this);
        this.isShiftDown = this.isShiftDown.bind(this);
    }

    onChange(e) {
        const {value} = e;
        this.setState({
            'editorContent': value
        })
        if (this.props.onChange) {
            this.props.onChange(e, value, this.serializer.serialize);
        }
    }

    onPaste(e, editor, next) {
        const xfer = getEventTransfer(e);

        if (embedHasCursor()) {
            return next();
        }

        if (xfer.type != 'html') {
            return next();
        }

        // keep this paste event from handling the script paste event
        if (xfer.text.search(/\<script/) !== -1 || xfer.text.search(/\<iframe/) === 0) {
            return next();
        }

        const text = xfer.html.replace(/\<br ?\/?\>/ig, '');
        const {document} = this.serializer.deserialize(text);
        editor.insertFragment(document);
    }

    onKeyUp(e, editor, next) {
        if (e.key === 'Shift') {
            this.setState({shiftDown: false});
            return next();
        }
    }

    onKeyDown(e, editor, next) {
        if (e.key === 'Shift') {
            this.setState({shiftDown: true});
            return next();
        }

        const value = editor.value;
        const document = value.document;
        const {start} = value.selection;

        if (e.key == 'Tab') {
            // check to see if we're in a list
            const isList = this.hasBlock('list-item');
            if (!isList) {
                return next();
            }

            const isBullet = value.blocks.some(block => {
                return !!document.getClosest(block.key, parent => parent.type == 'bulleted-list');
            });

            if (!this.isShiftDown()) {
                e.preventDefault();
                editor
                    .setBlocks('list-item')
                    .wrapBlock(isBullet ? 'bulleted-list' : 'ordered-list');
                return;
            } else {
                const listItem = document.getAncestors(start.key).find((block) => {
                    return block.type == 'list-item';
                });
                const firstParent = document.getClosest(listItem.key, (block) => {
                    return block.type == 'bulleted-list' || block.type == 'ordered-list';
                });
                const secondParent = document.getClosest(firstParent.key, (block) => {
                    return block.type == 'bulleted-list' || block.type == 'ordered-list';
                });
                if (!secondParent) {
                    return next();
                }
                e.preventDefault();
                editor.moveNodeByKey(listItem.key, secondParent.key, secondParent.nodes.size);
                return;
            }
        } else if (e.key == 'Enter') {
            const isList = this.hasBlock('list-item');
            if (!isList) {
                return next();
            }

            const listItem = document.getAncestors(start.key).find((block) => {
                return block.type == 'list-item';
            });

            if (listItem.text.length) {
                return next();
            }

            // it's empty, so turn the block into a graf so we're out of the list
            editor
                .setBlocks('paragraph')
                .unwrapBlock('bulleted-list')
                .unwrapBlock('ordered-list');

            // find the parent list
            const parent = document.getFurthest(listItem.key, (block) => {
                return block.type == 'bulleted-list' || block.type == 'ordered-list';
            });
            editor.moveToRangeOfNode(parent)
                  .moveToEndOfBlock();

            return;
        }

        return next();
    }

    renderNode(props, editor, next) {
        const {attributes, children} = props;

        switch(props.node.type) {
            case 'heading-one':
                return <h1 {...attributes}>{children}</h1>;
            case 'heading-two':
                return <h2 {...attributes}>{children}</h2>;
            case 'heading-three':
                return <h3 {...attributes}>{children}</h3>;
            case 'heading-four':
                return <h4 {...attributes}>{children}</h4>;
            case 'heading-five':
                return <h5 {...attributes}>{children}</h5>;
            case 'heading-six':
                return <h6 {...attributes}>{children}</h6>;
            case 'bulleted-list':
                return <ul {...attributes}>{children}</ul>;
            case 'ordered-list':
                return <ol {...attributes}>{children}</ol>;
            case 'list-item':
                return <li {...attributes}>{children}</li>;
            case 'blockquote':
                return <blockquote {...attributes}>{children}</blockquote>;
            case 'text-left':
                return <div style={{'textAlign': 'left'}} {...attributes}>{children}</div>;
            case 'text-right':
                return <div style={{'textAlign': 'right'}} {...attributes}>{children}</div>;
            case 'text-center':
                return <div style={{'textAlign': 'center'}} {...attributes}>{children}</div>;
            case 'code':
                return <CodeNode {...props} editor={this} />;
            default:
                return next();
        }
    }

    renderMark(props, editor, next) {
        switch(props.mark.type) {
            case 'bold':
                return <strong {...props.attributes}>{props.children}</strong>
            case 'italic':
                return <em {...props.attributes}>{props.children}</em>
            case 'underline':
                return <span style={{textDecoration:'underline'}} {...props.attributes}>{props.children}</span>
            default:
                return next();
        }
    }

    handleMarkClick(type, e) {
        e.preventDefault();
        this.editor.toggleMark(type);
    }

    handleNodeClick(type, e) {
        e.preventDefault();
        const editor = this.editor;
        const value = editor.value;
        const doc = value.document;

        if (type == 'ordered-list' || type == 'bulleted-list') {
            // are we currently in a list item
            const isList = this.hasBlock('list-item');
            // is it of the selected type
            const isType = value.blocks.some(block => {
                return !!doc.getClosest(block.key, parent => parent.type == type);
            });

            if (isList && isType) {
                editor
                    .setBlocks('paragraph')
                    .unwrapBlock('bulleted-list')
                    .unwrapBlock('ordered-list');
            } else if (isList) {
                editor
                    .unwrapBlock(type == 'bulleted-list' ? 'ordered-list' : 'bulleted-list')
                    .wrapBlock(type);
            } else {
                editor.setBlocks('list-item').wrapBlock(type);
                ensureEmptyGraf(editor);
            }

        } else {
            // Generic handler, no special cases
            const hasType = this.hasBlock(type);
            if (hasType) {
                editor.setBlocks('paragraph');
            } else {
                editor.setBlocks(type);
            }
        }
    }

    isShiftDown() {
        return this.state.shiftDown;
    }

    hasBlock(type) {
        const {editorContent} = this.state;
        return editorContent.blocks.some(node => node.type == type);
    }

    hasMark(type) {
        const {editorContent} = this.state;
        return editorContent.marks.some(mark => mark.type == type);
    }

    hasInline(type) {
        const {editorContent} = this.state;
        return editorContent.inlines.some(inline => inline.type == type);
    }

    handleExport(e) {
        const json = this.state.editorContent.toJSON();
        const html = this.serializer.serialize(this.state.editorContent);
        console.log('JSON', json);
        console.log('HTML', html);
    }

    handleFeedback() {
        const json = this.state.editorContent.toJSON();
        const html = this.serializer.serialize(this.state.editorContent);

        const n = '%0A';
        const mailSubject = encodeURIComponent('CEO Rich Editor Feedback');
        const mailContent = encodeURIComponent('---- Please keep the following to help us investigate the problem ----') + n
                          + encodeURIComponent('Client Code: ' + Config.get('client_code')) + n
                          + encodeURIComponent('Publication: ' + Config.get('publication_srn')) + n
                          + n
                          + encodeURIComponent(JSON.stringify(json)) + n
                          + n
                          + encodeURIComponent(html) + n
                          + encodeURIComponent('------- END DEBUG ------') + n;

        window.open('https://snworks.zendesk.com/hc/en-us/requests/new?__cifsub=' + mailSubject + '&__cifbod=' + mailContent, 'new-request');
    }

    handleHelp(e) {
        window.open('https://snworks.zendesk.com/hc/en-us/articles/360020388812', 'help');
    }

    resetEditorContent(value) {
        if (!this.state.editorContent) {
            return;
        }

        let initialValue = Plain.deserialize('');

        if (value) {
            if (value.indexOf('"object":"value"') !== -1) {
                initialValue = Value.fromJSON(JSON.parse(value));
            } else if (value.indexOf("entityMap") !== -1) {
                initialValue = Draft.deserialize(JSON.parse(value));
            } else if (value.search(/\<p/i) !== -1) {
                initialValue = Serializer.deserialize(value);
            } else {
                initialValue = Plain.deserialize(value ? value : '');
            }
        }

        this.setState({'editorContent': initialValue});
    }

    render() {
        const mod = navigator.appVersion.indexOf('Mac') !== -1 ? 'CMD' : 'CTL';
        return (
            <div>
                {
                    this.props.label
                    ? (
                        <React.Fragment>
                            <span className='fixed-label'>{this.props.label}</span><br />
                        </React.Fragment>
                    )
                    : ''
                }
                <div class="__rich-editor2-toolbar">
                    <MediaButton editor={this} title="Embed Media" disabled={this.props.readOnly} />
                    <EmbedButton editor={this} title="Embed Code" disabled={this.props.readOnly} />
                    <NoteButton editor={this} title="Add Note" disabled={this.props.readOnly} />
                    <CqButton editor={this} title="CQ Text" disabled={this.props.readOnly} />
                    {
                        this.props.keywords
                        ? <SuggestionButton editor={this} title="Suggest Links" disabled={this.props.readOnly} />
                        : ''
                    }
                    <ToolbarSeparator />
                    <ToolbarButton onClick={this.handleMarkClick.bind(this, 'bold')} title="Bold" disabled={this.props.readOnly}><i className='fa fa-bold'></i></ToolbarButton>
                    <ToolbarButton onClick={this.handleMarkClick.bind(this, 'italic')} title="Italic" disabled={this.props.readOnly}><i className='fa fa-italic'></i></ToolbarButton>
                    <ToolbarButton onClick={this.handleMarkClick.bind(this, 'underline')} title="Underline" disabled={this.props.readOnly}><i className='fa fa-underline'></i></ToolbarButton>
                    <AnchorButton editor={this} title="Add Link" disabled={this.props.readOnly} />
                    <StylerButton editor={this} title="Custom Styles" disabled={this.props.readOnly} />
                    <IconMenu
                        iconButtonElement={<IconButton className='fa fa-paragraph' />}
                        desktop={true}
                        className='__rich-editor2-toolbar-menu'
                        title="Formatting"
                        disabled={this.props.readOnly}
                        >
                        <MenuItem primaryText='Normal' onClick={this.handleNodeClick.bind(this, 'paragraph')} />
                        <MenuItem primaryText='Preformatted' secondaryText={mod + '+`'} onClick={this.handleNodeClick.bind(this, 'code')} />
                        <MenuItem primaryText='Blockquote' onClick={this.handleNodeClick.bind(this, 'blockquote')} />
                        <MenuItem primaryText="Heading 1" onClick={this.handleNodeClick.bind(this, 'heading-one')} />
                        <MenuItem primaryText="Heading 2" onClick={this.handleNodeClick.bind(this, 'heading-two')} />
                        <MenuItem primaryText="Heading 3" onClick={this.handleNodeClick.bind(this, 'heading-three')} />
                        <MenuItem primaryText="Heading 4" onClick={this.handleNodeClick.bind(this, 'heading-four')} />
                        <MenuItem primaryText="Heading 5" onClick={this.handleNodeClick.bind(this, 'heading-five')} />
                        <MenuItem primaryText="Heading 6" onClick={this.handleNodeClick.bind(this, 'heading-six')} />
                    </IconMenu>
                    <ToolbarButton disabled={this.props.readOnly} onClick={this.handleNodeClick.bind(this, 'text-right')} title="Align Right"><i className='fa fa-align-right'></i></ToolbarButton>
                    <ToolbarButton disabled={this.props.readOnly} onClick={this.handleNodeClick.bind(this, 'text-left')} title="Align Left"><i className='fa fa-align-left'></i></ToolbarButton>
                    <ToolbarButton disabled={this.props.readOnly} onClick={this.handleNodeClick.bind(this, 'text-center')} title="Align Center"><i className='fa fa-align-center'></i></ToolbarButton>
                    <ToolbarSeparator />
                    <HrButton editor={this} title="Horizontal Rule" disabled={this.props.readOnly} />
                    <ToolbarButton disabled={this.props.readOnly} onClick={this.handleNodeClick.bind(this, 'bulleted-list')} title="Bulleted List"><i className='fa fa-list-ul'></i></ToolbarButton>
                    <ToolbarButton disabled={this.props.readOnly} onClick={this.handleNodeClick.bind(this, 'ordered-list')} title="Numbered List"><i className='fa fa-list-ol'></i></ToolbarButton>
                    <IconMenu
                        iconButtonElement={<IconButton className='fa fa-table' />}
                        desktop={true}
                        className='__rich-editor2-toolbar-menu'
                        title="Insert Table"
                        disabled={this.props.readOnly}
                        >
                        <MenuItem primaryText='Insert Table'  onClick={tableClickHandler.bind(this)} />
                        <MenuItem primaryText='Add Row' onClick={tableAddRow.bind(this)} />
                        <MenuItem primaryText='Add Column' onClick={tableAddColumn.bind(this)} />
                        <MenuItem primaryText='Remove Row' onClick={tableRemoveRow.bind(this)} />
                        <MenuItem primaryText='Remove Column' onClick={tableRemoveColumn.bind(this)} />
                    </IconMenu>
                    <IconMenu
                        iconButtonElement={<IconButton className='fa fa-columns' />}
                        desktop={true}
                        className='__rich-editor2-toolbar-menu'
                        title="Insert Grid"
                        disabled={this.props.readOnly}
                        >
                        <MenuItem primaryText='Insert Grid' onClick={gridClickHandler.bind(this)} />
                        <MenuItem primaryText='Add Column' onClick={gridAddCellHandler.bind(this)} />
                        <MenuItem primaryText='Remove Column' onClick={gridRemoveCellHandler.bind(this)} />
                    </IconMenu>
                    <ToolbarSeparator />
                    <ToolbarButton disabled={this.props.readOnly} onClick={this.handleFeedback.bind(this)} title="Submit Feedback"><i className='fa fa-bug'></i></ToolbarButton>
                    <ToolbarButton onClick={this.handleHelp.bind(this)} title="HELP"><i className='fa fa-question-circle'></i></ToolbarButton>
                </div>
                <Editor
                    ref={editor => this.editor = editor}
                    value={this.state.editorContent}
                    onChange={this.onChange.bind(this)}
                    onPaste={this.onPaste.bind(this)}
                    renderNode={this.renderNode.bind(this)}
                    renderMark={this.renderMark.bind(this)}
                    onKeyDown={this.onKeyDown.bind(this)}
                    onKeyUp={this.onKeyUp.bind(this)}
                    plugins={this.plugins}
                    style={{'minHeight': '250px', 'maxHeight': (window.innerHeight - 100) + 'px', 'overflow': 'auto'}}
                    className='__rich-editor2-editor'
                    readOnly={this.props.readOnly}
                    schema={defaultSchema}

                    contentVersion={this.props.contentVersion}
                    contentUuid={this.props.contentUuid}
                    />
                <div style={{textAlign:'right',fontSize:'0.65em',color:'#ccc', textTransform:'uppercase'}}>
                    CEOEdit v2.0.3
                </div>
            </div>
        )
    }
}