Build a collaborative text editor with Gatsby and Draft.js
You will need Node and npm installed on your machine. Some knowledge of React will be helpful.
Introduction
Gatsby is a static site generator for React that uses latest frontend technologies like Webpack and GraphQL. It can generate optimized and blazing-fast sites from Markdown, APIs, Databases, YAML, JSON, CSV and even CMSs like Contentful, Drupal and Wordpress.
Draft.js is an open source framework from Facebook for building rich text editors in React. It is powered by an immutable model and abstracts over cross-browser differences.
In this post we’ll be combining the power of Gatsby, Draft.js and Pusher to build a realtime editor. Here’s a sneak-peak into what we’ll be building:
Prerequisites
To follow this tutorial, you need Node and NPM installed on your machine. A basic understanding of React will help you get the most out of this tutorial.
Install Gatsby
Gatsby supports versions of Node.js back to v6 and NPM to v3. If you don’t have Node.js installed, go to https://nodejs.org/ and install the recommended version for your operating system.
To start a Gatsby app, we need to first install the Gatsby command line by running the following in the terminal:
$ npm install --global gatsby-cli
Once that is installed, still in your terminal, run the following command to create a new Gatsby site in a directory called pusher-editor
and then move to this new directory:
$ gatsby new pusher-editor
$ cd pusher-editor
Once in the pusher-editor
directory, you can run Gatsby’s built-in development server by running the following command:
$ gatsby develop
This starts up the development server which you can access at http://localhost:8000 from your browser. The Gatsby built in development server uses “hot reloading” which means changes made are instantly visible in the browser without reloading.
Create a Pusher app
To create a Pusher app, you must have a Pusher account. Head over to Pusher and create a free account.
Create a new app by selecting Channels apps on the sidebar and clicking Create Channels app button on the bottom of the sidebar:
Configure an app by providing basic information requested in the form presented. You can also choose the environment you intend to integrate with Pusher, to be provided with some boilerplate setup code:
Click the App Keys tab to retrieve your keys
Setup the application
Now that we have our Pusher app, let’s make some minor changes to our new Gatsby site. Draft.js
supports unicode, and as a result, we need to add the charset
meta tag in the head
block of our app.
In the index.js
file which can be found in src/layouts/index.js
, there is a Layout
functional component. Replace its contents with the following code:
// src/layouts/index.js
const Layout = ({ children, data }) => (
<div>
<Helmet
title={data.site.siteMetadata.title}
meta={[
{ name: 'description', content: 'Sample' },
{ name: 'keywords', content: 'sample, something' },
{ name: 'charset', content: 'utf-8' } # add the 'charset' meta tag
]}
/>
<Header siteTitle={data.site.siteMetadata.title} />
<div className="container-fluid"> # apply bootstrap class to this div
{children()}
</div>
</div>
)
Next, we’ll change the name in the header of our app. In the root directory lives a gatsby-config.js
file. Change the site meta data title property:
# gatsby-config.js
module.exports = {
siteMetadata: {
title: 'Pusher Realtime Editor', # change this line to any title of your choice
},
plugins: ['gatsby-plugin-react-helmet'],
}
Lastly, let’s add some styles to our editor. In the index.css
file, add the following code:
/* src/layouts/index.css */
/* top of the file */
@import url('https://fonts.googleapis.com/css?family=Muli');
body {
margin: 0;
font-family: 'Muli', sans-serif !important; /* add this line to the body tag */
}
.RichEditor-root {
background: #fff;
border: 1px solid #ddd;
font-family: 'Georgia', serif;
font-size: 14px;
padding: 15px;
}
.RichEditor-editor {
border-top: 1px solid #ddd;
cursor: text;
font-size: 16px;
margin-top: 10px;
}
.RichEditor-editor .public-DraftEditorPlaceholder-root,
.RichEditor-editor .public-DraftEditor-content {
margin: 0 -15px -15px;
padding: 15px;
}
.RichEditor-editor .public-DraftEditor-content {
min-height: 100px;
}
.RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root {
display: none;
}
.RichEditor-editor .RichEditor-blockquote {
border-left: 5px solid #eee;
color: #666;
font-family: 'Hoefler Text', 'Georgia', serif;
font-style: italic;
margin: 16px 0;
padding: 10px 20px;
}
.RichEditor-editor .public-DraftStyleDefault-pre {
background-color: rgba(0, 0, 0, 0.05);
font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace;
font-size: 16px;
padding: 20px;
}
.RichEditor-controls {
font-family: 'Helvetica', sans-serif;
font-size: 14px;
margin-bottom: 5px;
user-select: none;
display: inline;
}
.RichEditor-styleButton {
color: #999;
cursor: pointer;
margin-right: 16px;
padding: 2px 0;
display: inline-block;
}
.RichEditor-activeButton {
color: #5890ff;
}
blockquote {
background: #f9f9f9;
border-left: 0.3rem solid #ccc;
margin: 1.5em 10px;
padding: 0.2em 0.5rem;
font-family: 'Hoefler Text', 'Georgia', serif;
font-style: italic;
border-top-left-radius: 0.2rem;
border-bottom-left-radius: 0.2rem;
}
Create a simple server
With our Gatsby app set up, we need a simple server from where we’ll notify Pusher of updates to our editor. Let’s install some packages we need for our realtime editor. In your terminal, run the following command:
$ yarn add draft-js draft-js-export-html pusher-js axios dotenv express body-parser pusher bootstrap
In the command above, we added some dependencies for our app. Here’s what each package does:
draft-js
- for creating our rich text editor.draft-js-export-html
- for converting our editor state to HTML.pusher-js
andpusher
- for communicating with Pusher.axios
- for making HTTP requests from Node.js.dotenv
- for storing environmental variables.express
- for creating a web application server framework for Node.js.body-parser
- a Node.js body parsing middleware for parsing incoming request bodies.bootstrap
- for designing and styling HTML/CSS.
Now we’ll create a simple Express server. In your code editor, open the pusher-editor
directory and create a server.js
and .env
file in the root directory of your app. Add the following code to both files respectively:
// server.js
require('dotenv').config()
let express = require('express');
let bodyParser = require('body-parser');
let Pusher = require('pusher');
let app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// enable cross-origin resource sharing
app.use(function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
// create a Pusher client
let pusher = new Pusher({
appId: process.env.PUSHER_APP_ID,
key: process.env.PUSHER_APP_KEY,
secret: process.env.PUSHER_APP_SECRET,
cluster: process.env.PUSHER_APP_CLUSTER,
});
// create a home route to test if the server works
app.get('/', function (req, res) {
res.send('all green');
});
// create a "save-text" route to update Pusher when a new text is added to the editor
app.post('/save-text', function (req, res) {
if (req.body.text && req.body.text.trim() !== '') {
// send a 'text-update' event on the 'editor' channel with the editor text
pusher.trigger('editor', 'text-update', { text: req.body.text });
res.status(200).send({ success: true, message: 'text broadcasted' })
} else {
res.status(400).send({ success: false, message: 'text not broadcasted' })
}
})
// create a "editor-text" route to update Pusher the latest state of our editor
app.post('/editor-text', function (req, res) {
if (req.body.text) {
// send a 'editor-update' event on the 'editor' channel with the editor current state
pusher.trigger('editor', 'editor-update', { text: req.body.text, selection: req.body.selection });
res.status(200).send({ success: true, message: 'editor update broadcasted' })
} else {
res.status(400).send({ success: false, message: 'editor update not broadcasted' })
}
})
let port = process.env.PORT || 5000;
console.log(`server running on port ${port}`)
// run the server on our specified port
app.listen(port);
// .env
// add your Pusher keys here
PUSHER_APP_ID="YOUR APP ID"
PUSHER_APP_KEY="YOUR APP KEY"
PUSHER_APP_SECRET="YOUR APP SECRET"
PUSHER_APP_CLUSTER="YOUR APP CLUSTER"
In the server.js
file, we created a simple server with two routes for updating Pusher with the editor state and the editor’s HTML content. With this, Pusher is aware our editor text and current state.
To run this server, open the pusher-editor
directory in another terminal window and run the following command:
$ node server.js
Create the editor component
In your code editor, open the pusher-editor
directory and locate the index.js
file in src/pages/index.js
. Clear out all the code in the file and let’s import our packages and some components:
// src/pages/index.js
import React, { Component } from 'react'
import { Editor, EditorState, RichUtils, getDefaultKeyBinding, convertToRaw, convertFromRaw, SelectionState } from 'draft-js';
import { stateToHTML } from 'draft-js-export-html'
import Pusher from 'pusher-js';
import axios from 'axios'
import BlockStyleControls from '../components/blockStyleControls'
import InlineStyleControls from '../components/inlineStylesControls'
import 'bootstrap/dist/css/bootstrap.css'
const styleMap = {
CODE: {
backgroundColor: 'rgba(0, 0, 0, 0.05)',
fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace',
fontSize: 16,
padding: 2,
},
};
Next let’s create our rich editor component:
// src/pages/index.js
...
class RichEditor extends Component {
constructor(props) {
super(props);
this.state = { editorState: EditorState.createEmpty(), text: '', };
this.focus = () => this.refs.editor.focus();
this.onChange = (editorState) => {
this.setState({ editorState })
};
this.handleKeyCommand = this._handleKeyCommand.bind(this);
this.mapKeyToEditorCommand = this._mapKeyToEditorCommand.bind(this);
this.toggleBlockType = this._toggleBlockType.bind(this);
this.toggleInlineStyle = this._toggleInlineStyle.bind(this);
this.getBlockStyle = this._getBlockStyle.bind(this);
}
}
export default RichEditor
In the code snippet above, we created a class component with a constructor that contains our component’s state and methods.
Just before our component mounts, we want to connect to Pusher and subscribe to the editor
channel. To achieve this, we’ll use React’s componentWillMount
life cycle method. Add the following code inside the RichEditor
component:
// src/pages/index.js
...
class RichEditor extends Component {
...
componentWillMount() {
this.pusher = new Pusher('YOUR PUSHER KEY', {
cluster: 'eu',
encrypted: true
});
this.channel = this.pusher.subscribe('editor');
}
}
export default RichEditor
Remember to add your Pusher key in the code above.
Now that our Pusher client is subscribed to the editor
channel, we want to listen for the text-update
and editor-update
events, so we can update our component state with new data.
To achieve this, we’ll use React’s componentDidMount
life cycle method. Add the following code inside the RichEditor
component:
// src/pages/index.js
...
class RichEditor extends Component {
...
componentDidMount() {
let self = this;
// listen to 'text-update' events
this.channel.bind('text-update', function (data) {
// update the text state with new data
self.setState({ text: data.text })
});
// listen to 'editor-update' events
this.channel.bind('editor-update', function (data) {
// create a new selection state from new data
let newSelection = new SelectionState({
anchorKey: data.selection.anchorKey,
anchorOffset: data.selection.anchorOffset,
focusKey: data.selection.focusKey,
focusOffset: data.selection.focusOffset,
});
// create new editor state
let editorState = EditorState.createWithContent(convertFromRaw(data.text))
const newEditorState = EditorState.forceSelection(
editorState,
newSelection
);
// update the RichEditor's state with the newEditorState
self.setState({ editorState: newEditorState })
});
}
}
export default RichEditor
Draft.js
only provides the building blocks for a text editor, this means we have to write out all the functionality of our text editor ourselves. In our RichEditor
component, we’ll add some methods for simple editor functions like handling key commands, adding inline and block styles to text.
Add the following code inside your RichEditor
component:
# src/pages/index.js
...
class RichEditor extends Component {
...
// handle blockquote
_getBlockStyle(block) {
switch (block.getType()) {
case 'blockquote': return 'RichEditor-blockquote';
default: return null;
}
}
// handle key commands
_handleKeyCommand(command, editorState) {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {
this.onChange(newState);
return true;
}
return false;
}
// map the TAB key to the editor
_mapKeyToEditorCommand(e) {
if (e.keyCode === 9 /* TAB */) {
const newEditorState = RichUtils.onTab(
e,
this.state.editorState,
4, /* maxDepth */
);
if (newEditorState !== this.state.editorState) {
this.onChange(newEditorState);
}
return;
}
return getDefaultKeyBinding(e);
}
// toggle block styles
_toggleBlockType(blockType) {
this.onChange(
RichUtils.toggleBlockType(
this.state.editorState,
blockType
)
);
}
// toggle inline styles
_toggleInlineStyle(inlineStyle) {
this.onChange(
RichUtils.toggleInlineStyle(
this.state.editorState,
inlineStyle
)
);
}
}
export default RichEditor
Next, let’s render the actual component:
// src/pages/index.js
...
class RichEditor extends Component {
...
render() {
const { editorState } = this.state;
// If the user changes block type before entering any text, hide the placeholder.
let className = 'RichEditor-editor';
var contentState = editorState.getCurrentContent();
if (!contentState.hasText()) {
if (contentState.getBlockMap().first().getType() !== 'unstyled') {
className += ' RichEditor-hidePlaceholder';
}
}
return (
<div className="container-fluid">
<div className="row">
<div className="RichEditor-root col-12 col-md-6">
{/* render our editor block style controls components */}
<BlockStyleControls
editorState={editorState}
onToggle={this.toggleBlockType}
/>
{/* render our editor's inline style controls components */}
<InlineStyleControls
editorState={editorState}
onToggle={this.toggleInlineStyle}
/>
<div className={className} onClick={this.focus}>
{/* render the Editor exposed by Draft.js */}
<Editor
blockStyleFn={this.getBlockStyle}
customStyleMap={styleMap}
editorState={editorState}
handleKeyCommand={this.handleKeyCommand}
keyBindingFn={this.mapKeyToEditorCommand}
onChange={this.onChange}
placeholder="What's on your mind?"
ref="editor"
spellCheck={true}
/>
</div>
</div>
<div className="col-12 col-md-6">
{/* render a preview for the text in the editor */}
<div dangerouslySetInnerHTML={{ __html: this.state.text }} />
</div>
</div>
</div>
);
}
}
export default RichEditor
Lastly, let’s create the two components which we earlier imported into our RichEditor
component. In the src/components
directory, create three files; inlineStylesControls.js
, blockStyleControls.js
and styleButton.js
and add the following code respectively:
// src/components/inlineStylesControls.js
import React from 'react'
import StyleButton from './styleButton'
// define our inline styles
let INLINE_STYLES = [
{ label: 'Bold', style: 'BOLD' },
{ label: 'Italic', style: 'ITALIC' },
{ label: 'Underline', style: 'UNDERLINE' },
{ label: 'Monospace', style: 'CODE' },
];
const InlineStyleControls = (props) => {
const currentStyle = props.editorState.getCurrentInlineStyle();
return (
<div className="RichEditor-controls">
{/* map through our inline styles and display a style button for each /*}
{INLINE_STYLES.map((type) =>
<StyleButton
key={type.label}
active={currentStyle.has(type.style)}
label={type.label}
onToggle={props.onToggle}
style={type.style}
/>
)}
</div>
);
};
export default InlineStyleControls
// src/components/blockStyleControls.js
import React, { Component } from 'react'
import StyleButton from './styleButton'
// define our block styles
const BLOCK_TYPES = [
{ label: 'H1', style: 'header-one' },
{ label: 'H2', style: 'header-two' },
{ label: 'H3', style: 'header-three' },
{ label: 'H4', style: 'header-four' },
{ label: 'H5', style: 'header-five' },
{ label: 'H6', style: 'header-six' },
{ label: 'Blockquote', style: 'blockquote' },
{ label: 'UL', style: 'unordered-list-item' },
{ label: 'OL', style: 'ordered-list-item' },
{ label: 'Code Block', style: 'code-block' },
];
const BlockStyleControls = (props) => {
const { editorState } = props;
const selection = editorState.getSelection();
const blockType = editorState
.getCurrentContent()
.getBlockForKey(selection.getStartKey())
.getType();
return (
<div className="RichEditor-controls">
{/* map through our block styles and display a style button for each */}
{BLOCK_TYPES.map((type) =>
<StyleButton
key={type.label}
active={type.style === blockType}
label={type.label}
onToggle={props.onToggle}
style={type.style}
/>
)}
</div>
);
};
export default BlockStyleControls
// src/components/styleButton.js
import React, { Component } from 'react'
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';
}
return (
<span className={className} onMouseDown={this.onToggle}>
{this.props.label}
</span>
);
}
}
export default StyleButton;
In the code snippets above, we have a StyleButton
component in src/components/styleButton.js
, which basically receives a text style and renders it in a span
tag on the page. In blockStyleControls.js
and inlineStylesControls.js
we have arrays that contain both block and inline text styles. We map through each style and pass them to the StyleButton
component which renders them.
Bringing it together
If you have followed the post to this point, you should have a working text editor on your page. To make the editor realtime, we need to notify Pusher every time there is a change in the editor’s state.
To do this, in our RichEditor
component’s onChange
method, we’ll make an AJAX request to our server with the new editor’s state so it can be broadcasted in realtime.
Update the RichEditor
component’s onChange
method with the following:
// src/pages/index.js
class RichEditor extends Component {
constructor(props) {
super(props);
this.state = { editorState: EditorState.createEmpty(), text: '', };
this.focus = () => this.refs.editor.focus();
this.onChange = (editorState) => { // update this line
// onChange, update editor state then notify pusher of the new editorState
this.setState({ editorState }, () => {
// call the function to notify Pusher of the new editor state
this.notifyPusher(stateToHTML(this.state.editorState.getCurrentContent()));
this.notifyPusherEditor(this.state.editorState)
})
}; // update ends here
this.handleKeyCommand = this._handleKeyCommand.bind(this);
this.mapKeyToEditorCommand = this._mapKeyToEditorCommand.bind(this);
this.toggleBlockType = this._toggleBlockType.bind(this);
this.toggleInlineStyle = this._toggleInlineStyle.bind(this);
this.getBlockStyle = this._getBlockStyle.bind(this);
this.notifyPusher = this._notifyPusher.bind(this); // add this line
this.notifyPusherEditor = this._notifyPusherEditor.bind(this); // add this line
}
...
// send the editor's text with axios to the server so it can be broadcasted by Pusher
_notifyPusher(text) {
axios.post('http://localhost:5000/save-text', { text })
}
// send the editor's current state with axios to the server so it can be broadcasted by Pusher
_notifyPusherEditor(editorState) {
const selection = editorState.getSelection()
let text = convertToRaw(editorState.getCurrentContent())
axios.post('http://localhost:5000/editor-text', { text, selection })
}
...
render() {
....
}
}
export default RichEditor
With that, if you open your app in a second browser tab and type in your editor, you should get realtime updates in your second tab. Ensure the server we added is running.
Conclusion
That’s it! In this post, you’ve learned how to build a realtime collaborative editor with Gatsby, Draft.js and Pusher. I can’t wait to see what you build with the new knowledge you’ve gained. You can find the source code for the demo app on GitHub.
12 June 2018
by Christian Nwamba