DEV Community

Rose
Rose

Posted on

Rich text editing on the web: Formatting text and keyboard shortcuts in Draft.js

☀️ Hello! If you are here, know nothing about Draft.js, and you are just jumping in, I recommend you check out Rich text editing on the web: Getting started with Draft.js, as we’ll be picking up where we left off 🙂


This post is going to get more of the basics set up so that we can be free to start exploring more fun topics next time.

Here is what we’ll look at adding:

  • Adding in all the inline style buttons we didn’t cover in the last post (code, underline, strikethrough)
  • Keyboard shortcuts to apply inline styles
  • Adding block-level styles (e.g. blockquote, unordered lists)
  • Keyboard shortcuts to apply block-level styles
  • You can see the final built-out version of this tutorial here

It’s a lot of stuff but we’ll move a little quicker, since a lot of what we’ll be doing builds directly on what we did last time.

🖍 small note: Although this entire tutorial is done in a single file, it could be very easily abstracted out into smaller components, and if you are building this in earnest, I would recommend doing so (ie perhaps StyleButton components and a Toolbar component). For the purpose of the tutorial, I decided to keep everything in one single component in the hopes that it would make things easier to work with for a tutorial instead of having to jump between files.


Adding the rest of the inline styles

As noted in the previous post, Draft.js supports bold, italic, strikethrough, underline, and code out-of-the-box. We already added bold and italic; I quickly popped in the rest. The logic is the exact same. If you notice the code looks a bit different, it’s because I moved the button rendering into its own method and defined a little array of all the styles, just to hopefully make it a bit cleaner.

So in App.js there is a new renderInlineStyle button, and render looks a bit different as well:

renderInlineStyleButton(value, style) {
    return (
      <input
        type="button"
        key={style}
        value={value}
        data-style={style}
        onMouseDown={this.toggleInlineStyle}
      />
    );
  }

  render() {
    const inlineStyleButtons = [
      {
        value: 'Bold',
        style: 'BOLD'
      },

      {
        value: 'Italic',
        style: 'ITALIC'
      },

      {
        value: 'Underline',
        style: 'UNDERLINE'
      },

      {
        value: 'Strikethrough',
        style: 'STRIKETHROUGH'
      },

      {
        value: 'Code',
        style: 'CODE'
      }
    ];

    return (
      <div className="my-little-app">
        <h1>Playing with Draft!</h1>
        <div className="inline-style-options">
          Inline Styles:
          {inlineStyleButtons.map((button) => {
            return this.renderInlineStyleButton(button.value, button.style);
          })}
        </div>
        <div className="draft-editor-wrapper">
          <Editor
            editorState={this.state.editorState}
            onChange={this.onChange}
          />
        </div>
      </div>
    );
  }

Since we already covered this in the last post and this was just a bit of additional housekeeping, I’ll quickly move on to the next item:

Adding keyboard shortcuts to apply inline styles

RichUtils, which is what we used for toggling inline styles with our buttons, also has a method for handling keyboard events 🙌

The Editor component takes a prop, handleKeyCommand. If we define a handleKeyCommand and pass it in as a prop, the Editor will call this method whenever it detects the user entering a keyboard command.

From that method, we can get RichUtils to do the heavy lifting for us, just like we did last time.

Our handleKeyCommand should look like this:

handleKeyCommand(command) {
    // inline formatting key commands handles bold, italic, code, underline
    const editorState = RichUtils.handleKeyCommand(this.state.editorState, command);

    if (editorState) {
      this.setState({editorState});
      return 'handled';
    }

    return 'not-handled';
  }

Just like with toggleInlineStyle, RichUtils.handleKeyCommand returns a new instance of editorState that we need to set on our state.

RichUtils.handleKeyCommand takes two arguments: The current editorState, and the key command entered.

The key commands are not standard JavaScript keyboard events, they are draft-specific “command” strings. There are some that exist out-of-the-box, and you can define your own as well.

If you were to add a console.log(command) in this method and then do the keyboard shortcut for bold (either cmd-b or ctrl-b depending on your OS), the console would log out the string bold . If you hit the backspace key, the console would log out backspace.

You want to make sure that if you are not doing anything with the key command you return the string not-handled . This ensures that if there’s any default Draft.js behaviour associated with that command, Draft.js knows that it’s OK to do its thing. If you return handled that will override any default behaviour.

So this is great but some inline styles don’t have a key command: What about strikethrough?

As you may have noticed from the comment in the code example, there’s no key command defined for strikethrough by default.

Luckily it is very easy to define something custom.

Right now when rendering your editor component it should look something like this:

  <Editor
      editorState={this.state.editorState}
      onChange={this.onChange}
      handleKeyCommand={this.handleKeyCommand}
 />

We want to pass in another function, keyBindingFn - this is what we can use to define some of our own key commands.

This function, when called, will be passed a keyboard event object. We can check it to see if a certain key is being pressed, and if the key is one that we want to associate with a command, we can return that command value as a string. That string will then make its way to the already defined handleKeyCommand

In this case we want to add a shortcut for strikethrough.

I’m going to copy Gmail’s pattern and make the shortcut for strikethrough cmd+shift+x (or ctrl+shift+x)

So we’ll detect this set of keys and then return the string 'strikethrough' if detected.

We also want to make sure we don’t break all the built-in key commands, so if we don’t detect strikethrough, we want to make sure Draft.js still parses it and detects default commands. We’ll do this by importing a function, getDefaultKeyBinding and calling it whenever we don’t detect a strikethrough shortcut.

SO.

Our import from draft-js is going to look like this now:

import { Editor, EditorState, RichUtils, getDefaultKeyBinding, KeyBindingUtil } from 'draft-js';

We’ve added two items here: getDefaultKeyBinding which I already mentioned, and KeyBindingUtil which provides some handy helper methods that we’ll make use of.

Now we need to write out our key binding function. It will look like this:

function keyBindingFunction(event) {
  if (KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === 'x') {
    return 'strikethrough';
  }

  return getDefaultKeyBinding(event);
}

As I said before, it first checks the event for the cmd-or-ctrl key using KeyBindingUtil.hasCommandModifier. Then it checks if the shiftKey is employed, and finally checks if the key itself is the letter x. If all 3 cases are true then it returns the string strikethrough. Otherwise it lets Draft.js parse the event using getDefaultKeyBinding and returns that value.

Now we can also pass this function into our editor -

<Editor
  editorState={this.state.editorState}
  onChange={this.onChange}
  handleKeyCommand={this.handleKeyCommand}
  keyBindingFn={keyBindingFunction}
/>

The final piece to get the strikethrough shortcut working is to update our handleKeyCommand function to check for our custom strikethrough command, and then tell it what to do if it encounters this command.

Our updated handleKeyCommand looks like this:

handleKeyCommand(command) {
    // inline formatting key commands handles bold, italic, code, underline
    var editorState = RichUtils.handleKeyCommand(this.state.editorState, command);

    // If RichUtils.handleKeyCommand didn't find anything, check for our custom strikethrough command and call `RichUtils.toggleInlineStyle` if we find it.
    if (!editorState && command === 'strikethrough') {
      editorState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH');
    }

    if (editorState) {
      this.setState({editorState});
      return 'handled';
    }

    return 'not-handled';
  }

And that’s that. We now have keyboard shortcuts defined for all our inline styles 👍

Moving on to the block level styles, like block quote and ordered list

Adding in support for block level styles is very similar to what we just did for inline styles. RichUtils has a toggleBlockType method that takes editorState as its first argument and a string representing a block type as the second argument. It returns a new instance of editorState. So as you can see, very similar to how toggleInlineStyle works.

Default block types supported are:

  • header-one
  • header-two
  • header-three
  • header-four
  • header-five
  • header-six
  • blockquote
  • code-block
  • atomic
  • unordered-list-item
  • ordered-list-item

So, for example, if we wanted to toggle a blockquote, we’d do something like:

const editorState = RichUtils.toggleBlockType(this.state.editorState, 'blockquote');
this.setState({editorState});

Since the logic here is so similar to the inline style buttons, instead of showing all the individual steps taken to add this in, I’m instead going to provide you with what my App.js now looks like as a whole, including the new block-level buttons:

import React from 'react';
import './App.css';
import { Editor, EditorState, RichUtils, getDefaultKeyBinding, KeyBindingUtil } from 'draft-js';

function keyBindingFunction(event) {
  if (KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === 'x') {
    return 'strikethrough';
  }

  return getDefaultKeyBinding(event);
}

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

    this.onChange = this.onChange.bind(this);
    this.handleKeyCommand = this.handleKeyCommand.bind(this);
    this.toggleInlineStyle = this.toggleInlineStyle.bind(this);
    this.toggleBlockType = this.toggleBlockType.bind(this);
  }

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

  handleKeyCommand(command) {
    // inline formatting key commands handles bold, italic, code, underline
    var editorState = RichUtils.handleKeyCommand(this.state.editorState, command);

    if (!editorState && command === 'strikethrough') {
      editorState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH');
    }

    if (editorState) {
      this.setState({editorState});
      return 'handled';
    }

    return 'not-handled';
  }

  toggleInlineStyle (event) {
    event.preventDefault();

    let style = event.currentTarget.getAttribute('data-style');
    this.setState({
      editorState: RichUtils.toggleInlineStyle(this.state.editorState, style)
    });
  }

  toggleBlockType (event) {
    event.preventDefault();

    let block = event.currentTarget.getAttribute('data-block');
    this.setState({
      editorState: RichUtils.toggleBlockType(this.state.editorState, block)
    });
  }

  renderBlockButton(value, block) {
    return (
      <input
        type="button"
        key={block}
        value={value}
        data-block={block}
        onMouseDown={this.toggleBlockType}
      />
    );
  }

  renderInlineStyleButton(value, style) {
    return (
      <input
        type="button"
        key={style}
        value={value}
        data-style={style}
        onMouseDown={this.toggleInlineStyle}
      />
    );
  }

  render() {
    const inlineStyleButtons = [
      {
        value: 'Bold',
        style: 'BOLD'
      },

      {
        value: 'Italic',
        style: 'ITALIC'
      },

      {
        value: 'Underline',
        style: 'UNDERLINE'
      },

      {
        value: 'Strikethrough',
        style: 'STRIKETHROUGH'
      },

      {
        value: 'Code',
        style: 'CODE'
      }
    ];

    const blockTypeButtons = [
      {
        value: 'Heading One',
        block: 'header-one'
      },

      {
        value: 'Heading Two',
        block: 'header-two'
      },

      {
        value: 'Heading Three',
        block: 'header-three'
      },

      {
        value: 'Blockquote',
        block: 'blockquote'
      },

      {
        value: 'Unordered List',
        block: 'unordered-list-item'
      },

      {
        value: 'Ordered List',
        block: 'ordered-list-item'
      }
    ];

    return (
      <div className="my-little-app">
        <h1>Playing with Draft!</h1>
        <div className="inline-style-options">
          Inline Styles:
          {inlineStyleButtons.map((button) => {
            return this.renderInlineStyleButton(button.value, button.style);
          })}
        </div>

        <div className="block-style-options">
          Block Types:
          {blockTypeButtons.map((button) => {
            return this.renderBlockButton(button.value, button.block);
          })}
        </div>
        <div className="draft-editor-wrapper">
          <Editor
            editorState={this.state.editorState}
            onChange={this.onChange}
            handleKeyCommand={this.handleKeyCommand}
            keyBindingFn={keyBindingFunction}
          />
        </div>
      </div>
    );
  }
}

export default App;

Wow, this post is getting long! Better wrap up quick 🙂

The final TODO is adding in more custom keyboard shortcuts for these block level items. RichUtils doesn’t have anything built in, so we need to do the same thing as what we did for strikethrough. Again, I’ll copy the shortcuts that Gmail uses for numbered list, bulleted list, and blockquote. Maybe you can add in your own shortcuts for headings as a project of your own!

Here is the final code that we’re wrapping up with, which includes these new shortcuts:

import React from 'react';
import './App.css';
import { Editor, EditorState, RichUtils, getDefaultKeyBinding, KeyBindingUtil } from 'draft-js';

function keyBindingFunction(event) {
  if (KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === 'x') {
    return 'strikethrough';
  }

  if (KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === '7') {
    return 'ordered-list';
  }

  if (KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === '8') {
    return 'unordered-list';
  }

  if (KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === '9') {
    return 'blockquote';
  }

  return getDefaultKeyBinding(event);
}

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

    this.onChange = this.onChange.bind(this);
    this.handleKeyCommand = this.handleKeyCommand.bind(this);
    this.toggleInlineStyle = this.toggleInlineStyle.bind(this);
    this.toggleBlockType = this.toggleBlockType.bind(this);
  }

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

  handleKeyCommand(command) {
    // inline formatting key commands handles bold, italic, code, underline
    var editorState = RichUtils.handleKeyCommand(this.state.editorState, command);

    if (!editorState && command === 'strikethrough') {
      editorState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH');
    }

    if (!editorState && command === 'blockquote') {
      editorState = RichUtils.toggleBlockType(this.state.editorState, 'blockquote');
    }

    if (!editorState && command === 'ordered-list') {
      editorState = RichUtils.toggleBlockType(this.state.editorState, 'ordered-list-item');
    }

    if (!editorState && command === 'unordered-list') {
      editorState = RichUtils.toggleBlockType(this.state.editorState, 'unordered-list-item');
    }

    if (editorState) {
      this.setState({editorState});
      return 'handled';
    }

    return 'not-handled';
  }

  toggleInlineStyle (event) {
    event.preventDefault();

    let style = event.currentTarget.getAttribute('data-style');
    this.setState({
      editorState: RichUtils.toggleInlineStyle(this.state.editorState, style)
    });
  }

  toggleBlockType (event) {
    event.preventDefault();

    let block = event.currentTarget.getAttribute('data-block');
    this.setState({
      editorState: RichUtils.toggleBlockType(this.state.editorState, block)
    });
  }

  renderBlockButton(value, block) {
    return (
      <input
        type="button"
        key={block}
        value={value}
        data-block={block}
        onMouseDown={this.toggleBlockType}
      />
    );
  }

  renderInlineStyleButton(value, style) {
    return (
      <input
        type="button"
        key={style}
        value={value}
        data-style={style}
        onMouseDown={this.toggleInlineStyle}
      />
    );
  }

  render() {
    const inlineStyleButtons = [
      {
        value: 'Bold',
        style: 'BOLD'
      },

      {
        value: 'Italic',
        style: 'ITALIC'
      },

      {
        value: 'Underline',
        style: 'UNDERLINE'
      },

      {
        value: 'Strikethrough',
        style: 'STRIKETHROUGH'
      },

      {
        value: 'Code',
        style: 'CODE'
      }
    ];

    const blockTypeButtons = [
      {
        value: 'Heading One',
        block: 'header-one'
      },

      {
        value: 'Heading Two',
        block: 'header-two'
      },

      {
        value: 'Heading Three',
        block: 'header-three'
      },

      {
        value: 'Blockquote',
        block: 'blockquote'
      },

      {
        value: 'Unordered List',
        block: 'unordered-list-item'
      },

      {
        value: 'Ordered List',
        block: 'ordered-list-item'
      }
    ];

    return (
      <div className="my-little-app">
        <h1>Playing with Draft!</h1>
        <div className="inline-style-options">
          Inline Styles:
          {inlineStyleButtons.map((button) => {
            return this.renderInlineStyleButton(button.value, button.style);
          })}
        </div>

        <div className="block-style-options">
          Block Types:
          {blockTypeButtons.map((button) => {
            return this.renderBlockButton(button.value, button.block);
          })}
        </div>
        <div className="draft-editor-wrapper">
          <Editor
            editorState={this.state.editorState}
            onChange={this.onChange}
            handleKeyCommand={this.handleKeyCommand}
            keyBindingFn={keyBindingFunction}
          />
        </div>
      </div>
    );
  }
}

export default App;

And that’s it 👏

You now have a reasonably functioning little visual editor. You could pretty easily make this quite nice with some CSS to style things a little nicer than the ugly defaults we have in this demo, and you could also abstract out/tidy up some of the javascript if you so desire.

There’s still more to learn but you have the framework in place. I’m excited to move on to some more advanced topics next time!

Thanks so much for reading ❤️ If you're finding this helpful, OR if you are finding it too confusing and feel I should slow down or take more time to explain certain things, let me know in the comments. 🧸

You can see the final built-out version of this tutorial here

Discussion (3)

Collapse
shashwat2702 profile image
Shashwat Sinha

Love this explanation and tutorial. Draft-js documentation is not very self-explanatory. It also lacks examples.

Collapse
markomatijevic profile image
Marko-Matijevic • Edited on

Hi, I loved your explanation. It helped me understand draft.js. There is small change you need to for strikethrough to work:

  • KeyBindingUtil.hasCommandModifier(event) && event.shiftKey && event.key === 'X' (it wont catch lowercased letter "x" because shift key was clicked)
Collapse
lavaldi profile image
Claudia Valdivieso

Great tutorial series! I wonder if formatting changes can be made more semantic, I mean, if draft-js instead of just using classes to add formatting can also use HTML tags?