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>
)
}
}