
In this article, you will learn how to make WYSIWYG rich text editor using React and Draft.js. The source code of this React WYSIWYG editor is given below.
At the end of this article, I have provided links to view the Live Demo and Download the full source code from GitHub.
Here’s a screenshot of how our WYSIWYG Editor Using React and Draft.js will look.
{
"name": "react-draft-wysiwyg",
"version": "1.15.0",
"description": "A wysiwyg on top of DraftJS.",
"main": "dist/react-draft-wysiwyg.js",
"repository": {
"type": "git",
"url": "https://github.com/jpuri/react-draft-wysiwyg.git"
},
"author": "Jyoti Puri",
"devDependencies": {
"@babel/core": "^7.7.4",
"@babel/preset-env": "^7.7.4",
"@babel/preset-react": "^7.7.4",
"@babel/register": "^7.7.4",
"@storybook/react": "^5.2.8",
"autoprefixer": "^9.7.3",
"babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6",
"babel-plugin-transform-flow-strip-types": "^6.22.0",
"chai": "^4.2.0",
"cross-env": "^6.0.3",
"css-loader": "^3.2.1",
"draft-js": "^0.11.2",
"draftjs-to-html": "^0.9.0",
"draftjs-to-markdown": "^0.6.0",
"embed-video": "^2.0.4",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.15.1",
"eslint": "^6.7.2",
"eslint-config-airbnb": "^18.0.1",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-mocha": "^6.2.2",
"eslint-plugin-react": "^7.17.0",
"file-loader": "^5.0.2",
"flow-bin": "^0.113.0",
"immutable": "^4.0.0-rc.12",
"jsdom": "^15.2.1",
"mini-css-extract-plugin": "^0.8.0",
"mocha": "^6.2.2",
"postcss-loader": "^3.0.0",
"precss": "^4.0.0",
"react": "^16.12.0",
"react-addons-test-utils": "^15.6.2",
"react-dom": "^16.12.0",
"react-test-renderer": "^16.12.0",
"rimraf": "^3.0.0",
"sinon": "^7.5.0",
"style-loader": "^1.0.1",
"uglifyjs-webpack-plugin": "^2.2.0",
"url-loader": "^3.0.0",
"webpack": "^4.41.2",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10"
},
"dependencies": {
"classnames": "^2.2.6",
"draftjs-utils": "^0.10.2",
"html-to-draftjs": "^1.5.0",
"linkify-it": "^2.2.0",
"prop-types": "^15.7.2"
},
"peerDependencies": {
"draft-js": "^0.10.x || ^0.11.x",
"immutable": "3.x.x || 4.x.x",
"react": "0.13.x || 0.14.x || ^15.0.0-0 || 15.x.x || ^16.0.0-0 || ^16.x.x || ^17.x.x || ^18.x.x",
"react-dom": "0.13.x || 0.14.x || ^15.0.0-0 || 15.x.x || ^16.0.0-0 || ^16.x.x || ^17.x.x || ^18.x.x"
},
"scripts": {
"clean": "rimraf dist",
"build:webpack": "cross-env NODE_ENV=production webpack --mode production --config config/webpack.config.js",
"build": "npm run clean && npm run build:webpack",
"test": "cross-env BABEL_ENV=test mocha --require config/test-compiler.js config/test-setup.js src/**/*Test.js",
"lint": "eslint src",
"lintdocs": "eslint docs/src",
"flow": "flow; test $? -eq 0 -o $? -eq 2",
"check": "npm run lint && npm run flow",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"license": "MIT"
}import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
Editor,
EditorState,
RichUtils,
convertToRaw,
convertFromRaw,
CompositeDecorator,
getDefaultKeyBinding,
} 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';
class WysiwygEditor extends Component {
constructor(props) {
super(props);
const toolbar = mergeRecursive(defaultToolbar, props.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 = this.getStyleMap(props);
this.compositeDecorator = this.getCompositeDecorator(toolbar);
const editorState = this.createEditorState(this.compositeDecorator);
extractInlineStyle(editorState);
this.state = {
editorState,
editorFocused: false,
toolbar,
};
}
componentDidMount() {
this.modalHandler.init(this.wrapperId);
}
// todo: change decorators depending on properties recceived in componentWillReceiveProps.
componentDidUpdate(prevProps) {
if (prevProps === this.props) return;
const newState = {};
const { editorState, contentState } = this.props;
if (!this.state.toolbar) {
const toolbar = mergeRecursive(defaultToolbar, toolbar);
newState.toolbar = toolbar;
}
if (
hasProperty(this.props, 'editorState') &&
editorState !== prevProps.editorState
) {
if (editorState) {
newState.editorState = EditorState.set(editorState, {
decorator: this.compositeDecorator,
});
} else {
newState.editorState = EditorState.createEmpty(this.compositeDecorator);
}
} else if (
hasProperty(this.props, 'contentState') &&
contentState !== prevProps.contentState
) {
if (contentState) {
const newEditorState = this.changeEditorState(contentState);
if (newEditorState) {
newState.editorState = newEditorState;
}
} else {
newState.editorState = EditorState.createEmpty(this.compositeDecorator);
}
}
if (
prevProps.editorState !== editorState ||
prevProps.contentState !== contentState
) {
extractInlineStyle(newState.editorState);
}
if (Object.keys(newState).length) this.setState(newState);
this.editorProps = this.filterEditorProps(this.props);
this.customStyleMap = this.getStyleMap(this.props);
}
onEditorBlur = () => {
this.setState({
editorFocused: false,
});
};
onEditorFocus = event => {
const { onFocus } = this.props;
this.setState({
editorFocused: true,
});
const editFocused = this.focusHandler.isEditorFocused();
if (onFocus && editFocused) {
onFocus(event);
}
};
onEditorMouseDown = () => {
this.focusHandler.onEditorMouseDown();
};
keyBindingFn = event => {
if (event.key === 'Tab') {
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();
}
}
return null;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
if (SuggestionHandler.isOpen()) {
event.preventDefault();
}
}
return getDefaultKeyBinding(event);
};
onToolbarFocus = event => {
const { onFocus } = this.props;
if (onFocus && this.focusHandler.isToolbarFocused()) {
onFocus(event);
}
};
onWrapperBlur = event => {
const { onBlur } = this.props;
if (onBlur && this.focusHandler.isEditorBlur(event)) {
onBlur(event, this.getEditorState());
}
};
onChange = editorState => {
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 = ref => {
this.wrapper = ref;
};
setEditorReference = ref => {
if (this.props.editorRef) {
this.props.editorRef(ref);
}
this.editor = ref;
};
getCompositeDecorator = toolbar => {
const decorators = [
...this.props.customDecorators,
getLinkDecorator({
showOpenOptionOnHover: 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 ? this.state.editorState : null;
getSuggestions = () => this.props.mention && this.props.mention.suggestions;
afterChange = editorState => {
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',
'customStyleMap',
]);
getStyleMap = props => ({ ...getCustomStyleMap(), ...props.customStyleMap });
changeEditorState = contentState => {
const newContentState = convertFromRaw(contentState);
let { editorState } = this.state;
editorState = EditorState.push(
editorState,
newContentState,
'insert-characters'
);
editorState = EditorState.moveSelectionToEnd(editorState);
return editorState;
};
focusEditor = () => {
setTimeout(() => {
this.editor.focus();
});
};
handleKeyCommand = command => {
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 = event => {
if (SuggestionHandler.isOpen()) {
return true;
}
const { editorState } = this.state;
const newEditorState = handleNewLine(editorState, event);
if (newEditorState) {
this.onChange(newEditorState);
return true;
}
return false;
};
handlePastedTextFn = (text, html) => {
const { editorState } = this.state;
const {
handlePastedText: handlePastedTextProp,
stripPastedStyles,
} = this.props;
if (handlePastedTextProp) {
return handlePastedTextProp(text, html, editorState, this.onChange);
}
if (!stripPastedStyles) {
return handlePastedText(text, html, editorState, this.onChange);
}
return false;
};
preventDefault = event => {
if (
event.target.tagName === 'INPUT' ||
event.target.tagName === 'LABEL' ||
event.target.tagName === 'TEXTAREA'
) {
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}
keyBindingFn={this.keyBindingFn}
editorState={editorState}
onChange={this.onChange}
blockStyleFn={blockStyleFn}
customStyleMap={this.getStyleMap(this.props)}
handleReturn={this.handleReturn}
handlePastedText={this.handlePastedTextFn}
blockRendererFn={this.blockRendererFn}
handleKeyCommand={this.handleKeyCommand}
ariaLabel={ariaLabel || 'rdw-editor'}
blockRenderMap={blockRenderMap}
{...this.editorProps}
/>
</div>
</div>
);
}
}
WysiwygEditor.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,
handlePastedText: PropTypes.func,
};
WysiwygEditor.defaultProps = {
toolbarOnFocus: false,
toolbarHidden: false,
stripPastedStyles: false,
localization: { locale: 'en', translations: {} },
customDecorators: [],
};
export default WysiwygEditor;
// todo: evaluate draftjs-utils to move some methods here
// todo: move color near font-family.rdw-editor-main {
height: 100%;
overflow: auto;
box-sizing: border-box;
}
.rdw-editor-toolbar {
padding: 6px 5px 0;
border-radius: 2px;
border: 1px solid #F1F1F1;
display: flex;
justify-content: flex-start;
background: white;
flex-wrap: wrap;
font-size: 15px;
margin-bottom: 5px;
user-select: none;
}
.public-DraftStyleDefault-block {
margin: 1em 0;
}
.rdw-editor-wrapper:focus {
outline: none;
}
.rdw-editor-wrapper {
box-sizing: content-box;
}
.rdw-editor-main blockquote {
border-left: 5px solid #f1f1f1;
padding-left: 5px;
}
.rdw-editor-main pre {
background: #f1f1f1;
border-radius: 3px;
padding: 1px 10px;
}Google Chrome has dominated web browsing for over a decade with 71.77% global market share.…
Perplexity just made its AI-powered browser, Comet, completely free for everyone on October 2, 2025.…
You've probably heard about ChatGPT Atlas, OpenAI's new AI-powered browser that launched on October 21,…
Perplexity Comet became free for everyone on October 2, 2025, bringing research-focused AI browsing to…
ChatGPT Atlas launched on October 21, 2025, but it's only available on macOS. If you're…
Two AI browsers just entered the ring in October 2025, and they're both fighting for…