packages/riipen-ui/src/components/Editor.jsx
import clsx from "clsx";
import {
AtomicBlockUtils,
CompositeDecorator,
Editor as DraftJsEditor,
EditorState,
getDefaultKeyBinding,
Modifier,
RichUtils,
SelectionState
} from "draft-js";
import PropTypes from "prop-types";
import React from "react";
import css from "styled-jsx/css";
import ThemeContext from "../styles/ThemeContext";
import EditorBlockStyleControls from "./EditorBlockStyleControls";
import EditorInlineStyleControls from "./EditorInlineStyleControls";
import EditorImage from "./EditorImage";
import EditorUtils from "./EditorUtils";
// draft-js map of inline style types to the corresponding style displayed in the editor.
const styleMap = {
CODE: {
backgroundColor: "rgba(0, 0, 0, 0.05)",
fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace',
padding: 2
}
};
const getBlockComponent = block => {
switch (block.getType()) {
case "atomic":
return { component: EditorImage, editable: false };
default:
return null;
}
};
class Editor extends React.Component {
static displayName = "Editor";
static propTypes = {
/**
* Whether or not to move focus the Editor on first render.
*/
autoFocus: PropTypes.bool,
/**
* Action controls to display on the right side of the editor control bar.
*/
actionControls: PropTypes.arrayOf(PropTypes.node),
/**
* The id of the element to use as the aria-label for the Editor.
*/
ariaLabelledBy: PropTypes.string,
/**
* Optional array of whitelisted styles.
* ex. ['code-block', 'BOLD', 'LINK']
*/
controlWhitelist: PropTypes.arrayOf(PropTypes.string),
/**
* Position of controls for the editor.
*/
controlPosition: PropTypes.oneOf(["top", "bottom"]),
/**
* Disable the editor or not
*/
disabled: PropTypes.bool,
/**
* The decorator for the editor.
*/
decorator: PropTypes.shape({ type: PropTypes.oneOf([CompositeDecorator]) }),
/**
* If the error style should be shown or not.
*/
error: PropTypes.any,
/**
* Whether or not to force the editor to focus on click.
*/
forceFocus: PropTypes.bool,
/**
* Whether or not to hide the controls + control row when on smaller screens.
*/
mobileControlRow: PropTypes.bool,
/**
* Initial content to set in the editor.
*/
initialValue: PropTypes.any,
/**
* Function to execute when editor content changes, gets html value of editor.
*/
onChange: PropTypes.func,
/**
* Function to execute when editor is out of focus.
*/
onBlur: PropTypes.func,
/**
* The max height of the Editor text area.
*/
maxHeight: PropTypes.string,
/**
* Optional placeholder. Shows when there is no text.
*/
placeholder: PropTypes.node,
/**
* Optional style applied to parent div of editor.
* Used to set a minimum height.
*/
style: PropTypes.object,
/**
* Additional action controls to display on the left side of the editor control bar.
*/
stylingControls: PropTypes.arrayOf(PropTypes.node)
};
static defaultProps = {
actionControls: [],
controlPosition: "top",
forceFocus: true,
maxHeight: "auto",
mobileControlRow: false,
stylingControls: []
};
constructor(props) {
super(props);
this.editor = React.createRef();
this.state = { editorState: undefined };
}
componentDidMount() {
const { decorator, initialValue } = this.props;
// Sets editor state after mount (because SSR)
const editorState = initialValue
? EditorState.createWithContent(
EditorUtils.fromHtml(initialValue || ""),
decorator
)
: EditorState.createEmpty(decorator);
this.onChange(editorState, false);
}
componentDidUpdate(prevProps) {
const { autoFocus } = this.props;
if (autoFocus && prevProps.autoFocus !== autoFocus) {
this.forceFocus();
}
}
onChange = (editorState, propagate = true) => {
this.setState({ editorState }, () => {
const html = this.getHtml();
if (this.props.onChange && propagate) {
this.props.onChange(html);
}
});
};
getLinkedStyles = () => {
const theme = this.context;
const { controlPosition, maxHeight } = this.props;
return css.resolve`
.wrapper {
background-color: ${theme.palette.common.white};
border: 1px solid ${theme.palette.grey[500]};
border-radius: ${theme.shape.borderRadius.md};
font-family: ${theme.typography.body1.fontFamily};
font-size: ${theme.typography.body1.fontSize};
line-height: ${theme.typography.body1.lineHeight};
min-height: 115px;
position: relative;
transition: ${theme.transitions.duration.standard}ms all;
width: 100%;
word-break: break-word;
}
.wrapper.disabled {
border-color: ${theme.palette.grey[400]};
opacity: 0.5;
pointer-events: none;
}
.wrapper.focus {
border-color: ${theme.palette.tertiary.main};
outline: 0;
}
.wrapper.error {
border-color: ${theme.palette.negative.main};
}
.editor.top {
border-bottom-left-radius: ${theme.shape.borderRadius.md};
border-bottom-right-radius: ${theme.shape.borderRadius.md};
border-top: ${controlPosition === "bottom"
? `1px solid ${theme.palette.grey[400]}`
: "none"};
}
.editor.bottom {
border-bottom: ${controlPosition === "top"
? `1px solid ${theme.palette.grey[400]}`
: "none"};
border-top-left-radius: ${theme.shape.borderRadius.md};
border-top-right-radius: ${theme.shape.borderRadius.md};
}
.editor {
background-color: ${theme.palette.common.white};
color: ${theme.palette.grey[600]};
cursor: text;
font-size: ${theme.typography.body1.fontSize};
/* 80px - 2 * 1px borders */
max-height: ${maxHeight};
min-height: 78px;
overflow-y: auto;
padding: ${theme.spacing(3)}px;
transition: ${theme.transitions.duration.standard}ms all;
}
.focus .editor {
background-color: ${theme.palette.common.white};
color: ${theme.palette.text.primary};
}
.controlContainer {
background-color: ${theme.palette.grey[100]};
display: flex;
flex: 1 1 auto;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
padding-left: ${theme.spacing(2)}px;
border-bottom-left-radius: ${theme.shape.borderRadius.md};
border-bottom-right-radius: ${theme.shape.borderRadius.md};
}
.stylingControls > div:not(:last-child) {
border-right: 1px solid ${theme.palette.divider};
}
@media (max-width: ${theme.breakpoints.sm}px) {
.controlContainer {
display: none;
}
.controlContainer.mobileControlRow {
display: flex;
}
.wrapper {
min-height: 0;
}
.editor.bottom,
.editor.top {
border-bottom: 0;
border-radius: ${theme.shape.borderRadius.md};
}
}
.controlRow {
display: inline-block;
margin: ${theme.spacing(1)}px 0;
user-select: none;
}
.blockquote {
border-left: 5px solid ${theme.palette.grey[400]};
color: ${theme.palette.text.secondary};
font-style: italic;
margin: 16px 0;
padding: ${theme.spacing(2)}px ${theme.spacing(4)}px;
}
/* Rich Text Editor */
:global(.DraftEditor-root) {
position: relative;
}
:global(.public-DraftEditorPlaceholder-inner) {
color: ${theme.palette.grey[400]};
}
:global(.public-DraftEditorPlaceholder-root) {
position: absolute;
}
:global(.richTextEditor-hidePlaceholder
.public-DraftEditorPlaceholder-root) {
display: none;
}
:global(.public-DraftEditorPlaceholder-hasFocus) {
display: none;
}
:global(.public-DraftStyleDefault-pre) {
background-color: rgba(0, 0, 0, 0.05);
font-family: "Inconsolata", "Menlo", "Consolas", monospace;
font-size: 16px;
padding: ${theme.spacing(4)}px;
}
:global(.public-DraftStyleDefault-block) {
margin-bottom: 1em;
}
`;
};
/*
* External method for setting content rather than relying on props update.
* Note: Does not keep editing history so use sparingly.
*/
setHtml = async html => {
const { decorator, autoFocus } = this.props;
const contentState = EditorUtils.fromHtml(html || "");
const editorState = EditorState.createWithContent(contentState, decorator);
await this.onChange(editorState);
if (autoFocus) {
this.focus();
}
};
getHtml = () => {
const html = EditorUtils.toHtml(this.state.editorState.getCurrentContent());
return this.hasContent() ? html : "";
};
// draft-js utility for styling blocks with css.
getBlockStyle = block => {
const linkedStyles = this.getLinkedStyles();
switch (block.getType()) {
case "blockquote":
return clsx(linkedStyles.className, "blockquote");
default:
return null;
}
};
hasContent = () => this.state.editorState.getCurrentContent().hasText();
handleKeyCommand = (command, editorState) => {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {
this.onChange(newState);
return true;
}
return false;
};
/* Bandaid solution for focus loss on enter :
only occurs when coming from certain routes. */
myKeyBindingFn = e => {
if (e.key === "Enter") e.stopPropagation();
return getDefaultKeyBinding(e);
};
focus = force => {
const { autoFocus, forceFocus } = this.props;
if (
(this.editor && this.editor.current && autoFocus) ||
(forceFocus && force)
) {
this.editor.current.focus();
}
};
forceFocus = () => this.focus(true);
// Control button callback for toggling block type
toggleBlockType = blockType => {
this.onChange(RichUtils.toggleBlockType(this.state.editorState, blockType));
};
// Control button callback for toggling inline styles
toggleInlineStyle = inlineStyle => {
this.onChange(
RichUtils.toggleInlineStyle(this.state.editorState, inlineStyle)
);
};
addText = text => {
const { editorState } = this.state;
const contentState = editorState.getCurrentContent();
const selectionState = editorState.getSelection();
let newContentState;
// Checks if text is highlighted to either insert/replace
if (selectionState.isCollapsed()) {
newContentState = Modifier.insertText(contentState, selectionState, text);
} else {
newContentState = Modifier.replaceText(
contentState,
selectionState,
text
);
}
const newEditorState = EditorState.push(
editorState,
newContentState,
"insert-characters"
);
this.onChange(newEditorState);
};
// Control button callback for adding a link
addLink = ({ text, url }) => {
const { editorState } = this.state;
const contentState = editorState.getCurrentContent();
const selectionState = editorState.getSelection();
// replace the current selection with the provided text
const newContentState = Modifier.replaceText(
contentState,
selectionState,
text
);
const contentStateWithEntity = newContentState.createEntity(
EditorUtils.EDITOR_ENTITY_TYPES.LINK,
"MUTABLE",
{ url }
);
// Get the first block key in the selection and force selection of inserted text
const blockKey = selectionState.getIsBackward()
? selectionState.getFocusKey()
: selectionState.getAnchorKey();
const anchorOffset = selectionState.getStartOffset();
const focusOffset = anchorOffset + text.length;
const newSelectionState = new SelectionState({
anchorKey: blockKey,
anchorOffset,
focusKey: blockKey,
focusOffset
});
const editorStateWithSelection = EditorState.forceSelection(
editorState,
newSelectionState
);
// Add the newly created link
const editorStateWithEntity = EditorState.set(editorStateWithSelection, {
currentContent: contentStateWithEntity
});
// Make the new link entity active.
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
const editorStateWithLink = RichUtils.toggleLink(
editorStateWithEntity,
editorStateWithEntity.getSelection(),
entityKey
);
this.onChange(editorStateWithLink);
};
/**
* Control button callback for toggling removing links.
* This breakdown of selection states is necessary because we don't want to clear the whole user's
* selection of all entities, only links.
*
* @param {Object.<string, Array.<Block>} ranges - Block keys to ranges, use the entityRanges object returned from getEntitesSelection.
*/
removeLinks = ranges => {
const blocks = Object.keys(ranges);
let newEditorState = this.state.editorState;
blocks.forEach(key => {
const block = ranges[key];
block.forEach(({ start, end }) => {
const selectionState = new SelectionState({
anchorKey: key,
anchorOffset: start,
focusKey: key,
focusOffset: end
});
const contentState = newEditorState.getCurrentContent();
const newContentState = Modifier.applyEntity(
contentState,
selectionState,
null
);
newEditorState = EditorState.set(newEditorState, {
currentContent: newContentState
});
});
});
this.onChange(newEditorState);
};
addImage = ({ url, altText }) => {
const { editorState } = this.state;
const contentState = editorState.getCurrentContent();
const contentStateWithEntity = contentState.createEntity(
EditorUtils.EDITOR_ENTITY_TYPES.IMAGE,
"IMMUTABLE",
{
alt: altText,
src: url
}
);
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
const editorStateWithEntity = EditorState.set(editorState, {
currentContent: contentStateWithEntity
});
const newEditorState = AtomicBlockUtils.insertAtomicBlock(
editorStateWithEntity,
entityKey,
" "
);
this.onChange(newEditorState);
};
static contextType = ThemeContext;
renderControls = () => {
const { stylingControls, actionControls, mobileControlRow } = this.props;
const { editorState } = this.state;
const linkedStyles = this.getLinkedStyles();
const controlContainerClasses = mobileControlRow
? ["controlContainer", "mobileControlRow"]
: "controlContainer";
return (
<>
<div className={clsx(linkedStyles.className, controlContainerClasses)}>
<div className={clsx(linkedStyles.className, "stylingControls")}>
<EditorBlockStyleControls
classes={[linkedStyles.className, "controlRow"]}
editorState={editorState}
toggle={this.toggleBlockType}
whitelist={this.props.controlWhitelist}
/>
<EditorInlineStyleControls
classes={[linkedStyles.className, "controlRow"]}
editorState={editorState}
toggle={this.toggleInlineStyle}
whitelist={this.props.controlWhitelist}
/>
{stylingControls &&
stylingControls.map((control, index) => (
<div
key={`control-${index}`}
className={clsx([linkedStyles.className, "controlRow"])}
>
{control}
</div>
))}
</div>
<div>
{actionControls &&
actionControls.map((control, index) => (
<div key={`control-${index}`}>{control}</div>
))}
</div>
</div>
</>
);
};
render() {
const {
ariaLabelledBy,
controlPosition,
forceFocus,
disabled,
error,
onBlur,
placeholder,
style
} = this.props;
const { editorState } = this.state;
if (!editorState) return null;
const linkedStyles = this.getLinkedStyles();
const wrapperClasses = clsx(
linkedStyles.className,
"wrapper",
disabled && "disabled",
editorState.getSelection().getHasFocus() && "focus",
error && "error"
);
const content = editorState.getCurrentContent();
const hidePlaceholder =
!content.hasText() &&
content
.getBlockMap()
.first()
.getType() !== "unstyled";
const editorClasses = clsx(
linkedStyles.className,
"editor",
disabled && "disabled",
controlPosition,
hidePlaceholder && "richTextEditor-hidePlaceholder"
);
return (
<>
<div
className={wrapperClasses}
onClick={forceFocus ? this.forceFocus : null}
>
{controlPosition === "top" && this.renderControls()}
<div className={editorClasses} style={style}>
<DraftJsEditor
ariaLabelledBy={ariaLabelledBy}
blockRendererFn={getBlockComponent}
blockStyleFn={this.getBlockStyle}
customStyleMap={styleMap}
editorState={editorState}
handleKeyCommand={this.handleKeyCommand}
keyBindingFn={this.myKeyBindingFn}
onBlur={onBlur}
onChange={this.onChange}
placeholder={placeholder}
ref={this.editor}
spellCheck
readOnly={disabled}
/>
</div>
{controlPosition === "bottom" && this.renderControls()}
</div>
{linkedStyles.styles}
</>
);
}
}
export default Editor;