react-draft-wysiwyg
Version:
A wysiwyg on top of DraftJS.
555 lines (519 loc) • 17 kB
JavaScript
/* @flow */
import React, { Component } from "react";
import PropTypes from "prop-types";
import {
Editor,
EditorState,
RichUtils,
convertToRaw,
convertFromRaw,
CompositeDecorator
} from "draft-js";
import {
changeDepth,
handleNewLine,
blockRenderMap,
getCustomStyleMap,
extractInlineStyle,
getSelectedBlocksType
} from "draftjs-utils";
import classNames from "classnames";
import ModalHandler from "../event-handler/modals";
import FocusHandler from "../event-handler/focus";
import KeyDownHandler from "../event-handler/keyDown";
import SuggestionHandler from "../event-handler/suggestions";
import blockStyleFn from "../utils/BlockStyle";
import { mergeRecursive } from "../utils/toolbar";
import { hasProperty, filter } from "../utils/common";
import { handlePastedText } from "../utils/handlePaste";
import Controls from "../controls";
import getLinkDecorator from "../decorators/Link";
import getMentionDecorators from "../decorators/Mention";
import getHashtagDecorator from "../decorators/HashTag";
import getBlockRenderFunc from "../renderer";
import defaultToolbar from "../config/defaultToolbar";
import localeTranslations from "../i18n";
import "./styles.css";
import "../../css/Draft.css";
export default class WysiwygEditor extends Component {
static propTypes = {
onChange: PropTypes.func,
onEditorStateChange: PropTypes.func,
onContentStateChange: PropTypes.func,
// initialContentState is deprecated
initialContentState: PropTypes.object,
defaultContentState: PropTypes.object,
contentState: PropTypes.object,
editorState: PropTypes.object,
defaultEditorState: PropTypes.object,
toolbarOnFocus: PropTypes.bool,
spellCheck: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types
stripPastedStyles: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types
toolbar: PropTypes.object,
toolbarCustomButtons: PropTypes.array,
toolbarClassName: PropTypes.string,
toolbarHidden: PropTypes.bool,
locale: PropTypes.string,
localization: PropTypes.object,
editorClassName: PropTypes.string,
wrapperClassName: PropTypes.string,
toolbarStyle: PropTypes.object,
editorStyle: PropTypes.object,
wrapperStyle: PropTypes.object,
uploadCallback: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onTab: PropTypes.func,
mention: PropTypes.object,
hashtag: PropTypes.object,
textAlignment: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
readOnly: PropTypes.bool,
tabIndex: PropTypes.number, // eslint-disable-line react/no-unused-prop-types
placeholder: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
ariaLabel: PropTypes.string,
ariaOwneeID: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
ariaActiveDescendantID: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
ariaAutoComplete: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
ariaDescribedBy: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
ariaExpanded: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
ariaHasPopup: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
customBlockRenderFunc: PropTypes.func,
wrapperId: PropTypes.number,
customDecorators: PropTypes.array,
editorRef: PropTypes.func
};
static defaultProps = {
toolbarOnFocus: false,
toolbarHidden: false,
stripPastedStyles: false,
localization: { locale: "en", translations: {} },
customDecorators: []
};
constructor(props) {
super(props);
const toolbar = mergeRecursive(defaultToolbar, props.toolbar);
this.state = {
editorState: undefined,
editorFocused: false,
toolbar
};
const wrapperId = props.wrapperId
? props.wrapperId
: Math.floor(Math.random() * 10000);
this.wrapperId = `rdw-wrapper-${wrapperId}`;
this.modalHandler = new ModalHandler();
this.focusHandler = new FocusHandler();
this.blockRendererFn = getBlockRenderFunc(
{
isReadOnly: this.isReadOnly,
isImageAlignmentEnabled: this.isImageAlignmentEnabled,
getEditorState: this.getEditorState,
onChange: this.onChange
},
props.customBlockRenderFunc
);
this.editorProps = this.filterEditorProps(props);
this.customStyleMap = getCustomStyleMap();
}
componentWillMount(): void {
this.compositeDecorator = this.getCompositeDecorator();
const editorState = this.createEditorState(this.compositeDecorator);
extractInlineStyle(editorState);
this.setState({
editorState
});
}
componentDidMount(): void {
this.modalHandler.init(this.wrapperId);
}
// todo: change decorators depending on properties recceived in componentWillReceiveProps.
componentWillReceiveProps(props) {
const newState = {};
if (this.props.toolbar !== props.toolbar) {
const toolbar = mergeRecursive(defaultToolbar, props.toolbar);
newState.toolbar = toolbar;
}
if (
hasProperty(props, "editorState") &&
this.props.editorState !== props.editorState
) {
if (props.editorState) {
newState.editorState = EditorState.set(props.editorState, {
decorator: this.compositeDecorator
});
} else {
newState.editorState = EditorState.createEmpty(this.compositeDecorator);
}
} else if (
hasProperty(props, "contentState") &&
this.props.contentState !== props.contentState
) {
if (props.contentState) {
const newEditorState = this.changeEditorState(props.contentState);
if (newEditorState) {
newState.editorState = newEditorState;
}
} else {
newState.editorState = EditorState.createEmpty(this.compositeDecorator);
}
}
if (
props.editorState !== this.props.editorState ||
props.contentState !== this.props.contentState
) {
extractInlineStyle(newState.editorState);
}
this.setState(newState);
this.editorProps = this.filterEditorProps(props);
this.customStyleMap = getCustomStyleMap();
}
onEditorBlur: Function = (): void => {
this.setState({
editorFocused: false
});
};
onEditorFocus: Function = (event): void => {
const { onFocus } = this.props;
this.setState({
editorFocused: true
});
const editFocused = this.focusHandler.isEditorFocused();
if (onFocus && editFocused) {
onFocus(event);
}
};
onEditorMouseDown: Function = (): void => {
this.focusHandler.onEditorMouseDown();
};
onTab: Function = (event): boolean => {
const { onTab } = this.props;
if (!onTab || !onTab(event)) {
const editorState = changeDepth(
this.state.editorState,
event.shiftKey ? -1 : 1,
4
);
if (editorState && editorState !== this.state.editorState) {
this.onChange(editorState);
event.preventDefault();
}
}
};
onUpDownArrow: Function = (event): boolean => {
if (SuggestionHandler.isOpen()) {
event.preventDefault();
}
};
onToolbarFocus: Function = (event): void => {
const { onFocus } = this.props;
if (onFocus && this.focusHandler.isToolbarFocused()) {
onFocus(event);
}
};
onWrapperBlur: Function = (event: Object) => {
const { onBlur } = this.props;
if (onBlur && this.focusHandler.isEditorBlur(event)) {
onBlur(event, this.getEditorState());
}
};
onChange: Function = (editorState: Object): void => {
const { readOnly, onEditorStateChange } = this.props;
if (
!readOnly &&
!(
getSelectedBlocksType(editorState) === "atomic" &&
editorState.getSelection().isCollapsed
)
) {
if (onEditorStateChange) {
onEditorStateChange(editorState, this.props.wrapperId);
}
if (!hasProperty(this.props, "editorState")) {
this.setState({ editorState }, this.afterChange(editorState));
} else {
this.afterChange(editorState);
}
}
};
setWrapperReference: Function = (ref: Object): void => {
this.wrapper = ref;
};
setEditorReference: Function = (ref: Object): void => {
if (this.props.editorRef) {
this.props.editorRef(ref);
}
this.editor = ref;
};
getCompositeDecorator = (): void => {
const decorators = [
...this.props.customDecorators,
getLinkDecorator({
showOpenOptionOnHover: this.state.toolbar.link.showOpenOptionOnHover
})
];
if (this.props.mention) {
decorators.push(
...getMentionDecorators({
...this.props.mention,
onChange: this.onChange,
getEditorState: this.getEditorState,
getSuggestions: this.getSuggestions,
getWrapperRef: this.getWrapperRef,
modalHandler: this.modalHandler
})
);
}
if (this.props.hashtag) {
decorators.push(getHashtagDecorator(this.props.hashtag));
}
return new CompositeDecorator(decorators);
};
getWrapperRef = () => this.wrapper;
getEditorState = () => this.state.editorState;
getSuggestions = () => this.props.mention && this.props.mention.suggestions;
afterChange: Function = (editorState): void => {
setTimeout(() => {
const { onChange, onContentStateChange } = this.props;
if (onChange) {
onChange(convertToRaw(editorState.getCurrentContent()));
}
if (onContentStateChange) {
onContentStateChange(convertToRaw(editorState.getCurrentContent()));
}
});
};
isReadOnly = () => this.props.readOnly;
isImageAlignmentEnabled = () => this.state.toolbar.image.alignmentEnabled;
createEditorState = compositeDecorator => {
let editorState;
if (hasProperty(this.props, "editorState")) {
if (this.props.editorState) {
editorState = EditorState.set(this.props.editorState, {
decorator: compositeDecorator
});
}
} else if (hasProperty(this.props, "defaultEditorState")) {
if (this.props.defaultEditorState) {
editorState = EditorState.set(this.props.defaultEditorState, {
decorator: compositeDecorator
});
}
} else if (hasProperty(this.props, "contentState")) {
if (this.props.contentState) {
const contentState = convertFromRaw(this.props.contentState);
editorState = EditorState.createWithContent(
contentState,
compositeDecorator
);
editorState = EditorState.moveSelectionToEnd(editorState);
}
} else if (
hasProperty(this.props, "defaultContentState") ||
hasProperty(this.props, "initialContentState")
) {
let contentState =
this.props.defaultContentState || this.props.initialContentState;
if (contentState) {
contentState = convertFromRaw(contentState);
editorState = EditorState.createWithContent(
contentState,
compositeDecorator
);
editorState = EditorState.moveSelectionToEnd(editorState);
}
}
if (!editorState) {
editorState = EditorState.createEmpty(compositeDecorator);
}
return editorState;
};
filterEditorProps = props =>
filter(props, [
"onChange",
"onEditorStateChange",
"onContentStateChange",
"initialContentState",
"defaultContentState",
"contentState",
"editorState",
"defaultEditorState",
"locale",
"localization",
"toolbarOnFocus",
"toolbar",
"toolbarCustomButtons",
"toolbarClassName",
"editorClassName",
"toolbarHidden",
"wrapperClassName",
"toolbarStyle",
"editorStyle",
"wrapperStyle",
"uploadCallback",
"onFocus",
"onBlur",
"onTab",
"mention",
"hashtag",
"ariaLabel",
"customBlockRenderFunc",
"customDecorators",
"handlePastedText"
]);
changeEditorState = contentState => {
const newContentState = convertFromRaw(contentState);
let { editorState } = this.state;
editorState = EditorState.push(
editorState,
newContentState,
"insert-characters"
);
editorState = EditorState.moveSelectionToEnd(editorState);
return editorState;
};
focusEditor: Function = (): void => {
setTimeout(() => {
this.editor.focus();
});
};
handleKeyCommand: Function = (command: Object): boolean => {
const { editorState, toolbar: { inline } } = this.state;
if (inline && inline.options.indexOf(command) >= 0) {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {
this.onChange(newState);
return true;
}
}
return false;
};
handleReturn: Function = (event: Object): boolean => {
if (SuggestionHandler.isOpen()) {
return true;
}
const editorState = handleNewLine(this.state.editorState, event);
if (editorState) {
this.onChange(editorState);
return true;
}
return false;
};
handlePastedText = (text, html) => {
const { editorState } = this.state;
if (this.props.handlePastedText) {
return this.props.handlePastedText(
text,
html,
editorState,
this.onChange
);
}
if (!this.props.stripPastedStyles) {
return handlePastedText(text, html, editorState, this.onChange);
}
return false;
};
preventDefault: Function = (event: Object) => {
if (event.target.tagName === "INPUT" || event.target.tagName === "LABEL") {
this.focusHandler.onInputMouseDown();
} else {
event.preventDefault();
}
};
render() {
const { editorState, editorFocused, toolbar } = this.state;
const {
locale,
localization: { locale: newLocale, translations },
toolbarCustomButtons,
toolbarOnFocus,
toolbarClassName,
toolbarHidden,
editorClassName,
wrapperClassName,
toolbarStyle,
editorStyle,
wrapperStyle,
uploadCallback,
ariaLabel
} = this.props;
const controlProps = {
modalHandler: this.modalHandler,
editorState,
onChange: this.onChange,
translations: {
...localeTranslations[locale || newLocale],
...translations
}
};
const toolbarShow =
editorFocused || this.focusHandler.isInputFocused() || !toolbarOnFocus;
return (
<div
id={this.wrapperId}
className={classNames(wrapperClassName, "rdw-editor-wrapper")}
style={wrapperStyle}
onClick={this.modalHandler.onEditorClick}
onBlur={this.onWrapperBlur}
aria-label="rdw-wrapper"
>
{!toolbarHidden && (
<div
className={classNames("rdw-editor-toolbar", toolbarClassName)}
style={{
visibility: toolbarShow ? "visible" : "hidden",
...toolbarStyle
}}
onMouseDown={this.preventDefault}
aria-label="rdw-toolbar"
aria-hidden={(!editorFocused && toolbarOnFocus).toString()}
onFocus={this.onToolbarFocus}
>
{toolbar.options.map((opt, index) => {
const Control = Controls[opt];
const config = toolbar[opt];
if (opt === "image" && uploadCallback) {
config.uploadCallback = uploadCallback;
}
return <Control key={index} {...controlProps} config={config} />;
})}
{toolbarCustomButtons &&
toolbarCustomButtons.map((button, index) =>
React.cloneElement(button, { key: index, ...controlProps })
)}
</div>
)}
<div
ref={this.setWrapperReference}
className={classNames(editorClassName, "rdw-editor-main")}
style={editorStyle}
onClick={this.focusEditor}
onFocus={this.onEditorFocus}
onBlur={this.onEditorBlur}
onKeyDown={KeyDownHandler.onKeyDown}
onMouseDown={this.onEditorMouseDown}
>
<Editor
ref={this.setEditorReference}
onTab={this.onTab}
onUpArrow={this.onUpDownArrow}
onDownArrow={this.onUpDownArrow}
editorState={editorState}
onChange={this.onChange}
blockStyleFn={blockStyleFn}
customStyleMap={getCustomStyleMap()}
handleReturn={this.handleReturn}
handlePastedText={this.handlePastedText}
blockRendererFn={this.blockRendererFn}
handleKeyCommand={this.handleKeyCommand}
ariaLabel={ariaLabel || "rdw-editor"}
blockRenderMap={blockRenderMap}
{...this.editorProps}
/>
</div>
</div>
);
}
}
// todo: evaluate draftjs-utils to move some methods here
// todo: move color near font-family