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