Building an editor with Draft.js and React

Over the past few months, we’ve been working in partnership with Automattic on a full re-write of the front-end of their popular Liveblog plugin for WordPress.

Share

This plugin enables editors to post updates right from the front-end of their site. To do this, you need an editor. The previous editor used by the plugin had some issues which we set out to improve during the rebuild. Our primary aims were to improve the user experience and create an editor which could be expanded upon in the future.

We researched various editors that are currently out there and what options were available to us. Building an editor isn’t an easy task for the web. The browser technologies that are available to do so can all be slightly different depending on your operating system or browser platform. This is where Draft.js comes in.

Draft.js is a framework for building rich text editors in React, powered by an immutable model and abstracting over cross-browser differences.

How does it work?

As we were using React to power the re-write of the front end, we were quickly able to integrate Draft.js into the app. Draft.js is essentially a content editable component of which its state is represented as an immutable data structure. In React, the DOM is represented as state. To edit the DOM you just need to update the state and React will update the DOM accordingly through its Virtual DOM implementation. Similarly, in Draft.js you only need to worry about controlling its EditorState. The EditorState object represents the entirety of the state of the editor, including contents, cursor and selection etc. Every time a change is made the EditorState object is updated. You have the ability to change the EditorState object as you wish and Draft.js takes care of updating the content. On top of this, it provides an API for interacting with the EditorState which includes a RichUtils module, used to give some rich text editing features, and a Modifier module which is useful for manipulating the content.

However, the real power of Draft.js comes with entities and custom block components. An entity is used for annotating text ranges with metadata. This reference to a text range allows you to target, manipulate or even decorate this specific text range. You can take this even further with custom block components which will enable you to render content like rich media in place of plain text.

This allowed us to build some exciting features from scratch in a relatively short amount of time which include:

  • Rich Text Editing
  • Autocomplete for Authors, Hashtags, Commands and Emojis
  • Emoji Rendering
  • Image Upload
  • Image Repositioning with Drag & Drop
  • Image Upload via File Drop

Moving forward with building the plugin we now have a solid foundation on which we can build new features. Previously the editor’s issues were mainly due to the plugins that were being used to build it. They relied on a lot of third-party code which made it difficult to edit. However, Draft.js’s primary aim is simply to create controlled content editable component allowing you to build your desired features on top of this. Going forward there are still things we can build to improve the editor. One thing we envision is supporting rendered oEmbeds in the editor itself, similar to how it works currently in the WordPress Dashboard.

Building a feature

What we are going to build

Let’s take a look at building a simplified version of the autocomplete feature for hashtags. If like me, you learn by digging around the code I have created a codepen demo of what we are going to build:

Basic Editor

Firstly let’s create the most basic version of an editor using Draft.js:

const { Editor, EditorState } = Draft;

class MyEditor extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        editorState: EditorState.createEmpty(),
    };
  }

  onChange(editorState) {
    this.setState({editorState});
  }

  render() {
    return <Editor editorState={this.state.editorState} onChange={this.onChange.bind(this)} />;
  }
}

Currently, this won’t give you much functionality. It will act similarly to a controlled input in React.

Detect hashtag

The next step is to listen for a trigger. In this case, we will be listening for a when a # character is inputted. To do this we can check after the editorState has been updated in our onChange function:

onChange(editorState) {
    this.setState({editorState}, () => {
       const triggerRange = getTriggerRange('#');
    });
}

By hooking into the onChange function we are able to check every time the editorState updates. Using setState’s callback means it will be executed after the state is updated. Now every time a user types we can check to see if our desired trigger is present. But how do we do this?

const getTriggerRange = (trigger) => {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;
  const range = selection.getRangeAt(0);
  const text = range.startContainer.textContent.substring(0, range.startOffset);
  if (/s+$/.test(text)) return null;
  const index = text.lastIndexOf(trigger);
  if (index === -1) return null;

  return {
    text: text.substring(index),
    start: index,
    end: range.startOffset,
  };
};

Using the native selection API, we can retrieve the text from when you start typing prefixed with the trigger character and the start and end range which will be useful later. Otherwise, we return null meaning that there was no trigger. From here we can use this data to render our suggestions.

Render suggestions

class MyEditor extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      editorState: EditorState.createEmpty(),
      autocompleteState: null
    };
  }

  onChange(editorState) {
    this.setState({editorState, () => {
        const triggerRange = getTriggerRange('#');

        if (!triggerRange) {
            this.setState({ autocompleteState: null });
            return;
        }

        this.setState({
            autocompleteState: {
                searchText: triggerRange.text.slice(1, triggerRange.text.length),
            },
        });
    });
  }

  render() {
    const { autocompleteState, editorState } = this.state;
    return (
      <div>
        <Editor
         ref={node => this.editor = node}
         editorState={editorState} 
         onChange={this.onChange.bind(this)}
         />
        <Suggestions 
          autocompleteState={autocompleteState}
        />
      </div>
    );
  }
}

Here we have created a new autocompleteState and we update it once we know there is a trigger. As this is a basic example we are just passing in the text following the trigger as that is currently all we need. For a more complex example, you might want to pass in extra data here. For example position values of the cursor. This would allow you to position the Suggestions component using these values.

We pass this new state into a new Suggestions component. Let’s have a closer look at this component:

class Suggestions extends React.Component {
  render() {
    const { autocompleteState } = this.props;
    if (!autocompleteState) return null;
    const { searchText } = autocompleteState;
    return (
      <div>
        <ul>
          {
            HASHTAGS
              .filter(item => item.substring(0, searchText.length) === searchText)
              .map(result => <li>{result}</li>)
          }
        </ul>
      </div>
    )
  }
}

In this component we render nothing if the autocompleteState is null. Otherwise, we filter a list of hashtags based on the input text we have received and display them as a list.

Render hashtag

Next, we want to choose an option from the suggestions lists and render it. To do this, we will need to use an entity that I mentioned earlier. To keep things simple we are going are only going render the suggestion on click. But Draft.js does make key handling easy meaning it is easier to bind listeners to the keyboard keys for a better experience.

First, you need to register a click handler to the suggestion item. We will pass a function back with the selected text via props that will handle this in our editor component to make it easier. Once we have done this, we need to handle it in our Editor component.

  renderSuggestion(text) {
    const { editorState, autocompleteState } = this.state; 

    this.onChange(
      addHashTag(editorState, autocompleteState, text) 
    );

    this.setState({ autocompleteState: null });
  }

  render() {
    const { autocompleteState, editorState } = this.state;
    return (
      <div>
        <Editor
         ref={node => this.editor = node}
         editorState={editorState} 
         onChange={this.onChange.bind(this)}
         />
        <Suggestions 
          autocompleteState={autocompleteState}
          renderSuggestion={(text) => this.renderSuggestion(text)}
        />
      </div>
    );
  }
}

To render the suggestion, we need to manually update our editor state through the on change function. Manually updating the editorState to do what you want isn’t quite as simple as using React’s setState so let’s break down what happens in the addHashTag function.

  1. We need to know the range of text we want to replace.
  2. We need to create a selection from the start and end of the range that we will use to replace with our hashtag.
  3. We create an entity to target this selection with metadata. It is worth noting the last argument takes an object of data. You can pass any data here you feel might be useful and it will be stored with the entity for reference at a later date.
  4. We create the new content using Draft.js’s Modifier utils. The selection is replaced with our hashtag and annotated with the entity metadata.
  5. Finally, we return a new editorState forcing the selection to be after the newly inserted content.
const addHashTag = (editorState, autocompleteState, hashtag) => {
  /* 1 */
  const { start, end } = getInsertRange(autocompleteState, editorState);

  /* 2 */
  const currentSelectionState = editorState.getSelection();
  const selection = currentSelectionState.merge({
    anchorOffset: start,
    focusOffset: end,
  });

  /* 3 */
  const contentState = editorState.getCurrentContent();
  const contentStateWithEntity = contentState.createEntity(
    'HASHTAG',
    'IMMUTABLE',
    {
      hashtag,
    },
  );
  const entityKey = contentStateWithEntity.getLastCreatedEntityKey();

  /* 4 */
  let newContentState = Modifier.replaceText(
    contentStateWithEntity,
    selection,
    `#${hashtag}`,
    null,
    entityKey,
  );

  /* 5 */
  const newEditorState = EditorState.push(
    editorState,
    newContentState,
    `insert-hashtag`,
  );

  return EditorState.forceSelection(
    newEditorState,
    newContentState.getSelectionAfter(),
  );
};

That seems like a lot of work to do such a simple thing. But Draft.js is doing a lot of work for you. Here we are updating Draft.js’s immutable data structure which is then rendered in the DOM without us doing anything. Also, remember that text range now has its own metadata. This means we always have reference to this specific piece of text as an entity. Entities come with some default behaviour. Notice how when we created the entity we created it as IMMUTABLE. This is one of three options we could have chosen:

  • Immutable: This text cannot be altered without removing the entity annotation from the text.
  • Mutable: This text may be altered freely.
  • Segmented: Segmented entities are tightly coupled to their text but allow customisation via deletion.

We have just created an inline entity. However, for more complex component blocks such as media, you may want to look at creating a Custom Block Component.

To create the final part we are going to create a decorator. Simply put, a decorator allows you to target a specific entity, and—instead of rendering just plain text—they allow you to render a custom component with access to all of the metadata you have stored with the entity.

Firstly when creating the initial editorState you need to make sure that it is created using Draft.js’s CompositeDecorate. This takes an array of objects with a strategy and component. The strategy finds the entity and the component is the component it will render in its place.

const Hashtag = ({ children }) => {
  return (
    <span style={{ background: 'lightBlue' }}>{children}</span>
  );
};

const findHashtagEntities = (contentState, contentBlock, callback) => {
  contentBlock.findEntityRanges(
    (character) => {
      const entityKey = character.getEntity();
      return (
        entityKey !== null &&
        contentState.getEntity(entityKey).getType() === 'HASHTAG'
      );
    },
    callback,
  );
};

/* ... */

constructor(props) {
    super(props);
    this.state = {
      editorState: EditorState.createEmpty(
        new CompositeDecorator([{
          strategy: findHashtagEntities,
          component: Hashtag,
        }]),
      ),
      autocompleteState: null
    };
  }

Summary

Now we have a basic autocomplete that renders an inline styled hashtag. There are many improvements to be made to what we have currently, but hopefully, this will help give you an idea on how to begin working with Draft.js to create an intuitive editing experience for your users.

Pitfalls

Unfortunately, it is not all good news. Draft.js doesn’t come without its own issues. Firstly, it is a relatively new technology which means that, although it has a strong community already, any open source projects based around it are mostly in beta. Draft.js also doesn’t have full mobile support as of the current date although appears to be moving towards this. Secondly, it has a steep learning curve itself without considering that you will first need a good understanding of React. Finally, as it only works with React, if you are not already using it then it is probably worth weighing up whether or not it is worth adding an extra dependency to the project.

Helpful Resources

As always in our community, there are a ton of resources out there to help you. I would highly recommend looking at Draft.js’s examples before getting started. I also found it useful looking at how the community has begun approaching building with Draft.js. Here are some valuable resources I found helpful:


Interested in learning more about enterprise WordPress?
Get in touch with the team today