Back to search

Build a collaborative text editor with Gatsby and Draft.js

  • Christian Nwamba
June 12th, 2018
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:

gatsby-collaborative-editing-demo

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.

gatsby-default-page

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:

gatsby-collaborative-editing-create-app

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:

gatsby-collaborative-editing-configure-app

Click the App Keys tab to retrieve your keys

gatsby-collaborative-editing-app-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 and pusher - 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.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 28 Scrutton Street, London EC2A 4RP.