Building an awesome editor for your React-based web application is by no means easy. But with SlateJS things get much easier. Even with the help of Slate, building a full-featured editor is way more work than we can cover in one blog post, so this post will give you the big picture and subsequent posts will dive into the dirty details.
Note: this post is based on a recent talk at the JavaScript NYC meetup. You can watch the video here:
We're building Kitemaker, a new, fast, and highly collaborative alternative to issue trackers like Jira, Trello, and Clubhouse. We're big believers in remote work, and in particular working asynchronously so that team members get large, uninterrupted blocks of time to get work done. A key to supporting this type of work is having a really great editor so that teams are inspired to collaborate together on issues and ensure alignment. And we think we've got the best editor around:
Markdown shortcuts, code blocks with syntax highlighting, images, embedding designs from Figma, math expressions with LaTex, diagrams with MermaidJS, and, of course, emojis ♥️. All done entirely with Slate.
So why did we choose to go with Slate in the first place? It's definitely not the only editor framework out there. For us, the things that pushed us towards Slate were:
One of the best parts of Slate is how it holds very few opinions about how documents are structured. It has only a few concepts:
Astute readers will notice that this is structured very similar to the DOM, and text, block and inlines in Slate behave very much like their counterparts in the DOM.
Here's an annotated screenshot of a Slate editor to explain these concepts visually:
Slate uses a very simple JSON format for representing documents, and the document above would look like this in Slate's representation:
[
{
"type": "paragraph",
"children": [
{
"text": "Text with a link "
},
{
"type": "link",
"url": "https://kitemaker.co",
"children": [
{
"text": "https://kitemaker.co"
}
]
},
{
"text": " here"
}
]
},
{
"type": "paragraph",
"children": [
{
"text": "Text with "
},
{
"text": "bold",
"bold": true
},
{
"text": " and "
},
{
"text": "italic",
"italic": true
},
{
"text": " here"
}
]
}
]
As we said before, Slate is really un-opinionated about how documents are structured. In the JSON blob above, the only thing Slate cares about is that it gets an array of block elements with a
children
property and that those children are either other block elements, or a mixture of text nodes and inline elements. That's it. Slate doesn't care about the type
, url
or bold
properties and it doesn't care about how these various nodes will be rendered. This makes it really flexible and powerful to work with.Enough background. Let's look at some code! Let's see what a simple editor component looks like using Slate:
function MyEditor() {
const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = React.useState<Node[]>([
{
children: [{ text: 'Testing' }],
},
]);
return (
<Slate editor={editor} value={value} onChange={(v) => setValue(v)}>
<Editable />
</Slate>
);
}
createEditor()
to make ourselves an editor (don't worry about withReact()
for now - that's a plugin, which we'll discuss below)<Slate>
component which serves as a context provider, providing the editor we created above to all of the components below it. This is really cool because you can, for example, add toolbars and other components below the <Slate>
component that can grab the editor and manipulate the document with it. We also wire up the value
and onChange
properties, similar to any input in React <Editable>
component which is the actual editor the user interacts with on the screenSo while that previous example was trivial to write, we still only have an editor that works about as well as
<TextArea>
. Not very exciting.Luckily Slate provides mechanisms for making things a whole lot more interesting:
Let's take a quick look at each of these.
Plugins are a deceptively simple, powerful concept in Slate. Plugins generally look something like this:
export function withMyPlugin(editor: ReactEditor) {
const { insertText, insertData, normalizeNode, isVoid, isInline } = editor;
// called whenever text is inserted into the document (e.g. when
// the user types something)
editor.insertText = (text) => {
// do something interesting!
insertText(text);
};
// called when the users pastes or drags things into the editor
editor.insertData = (data) => {
// do something interesting!
insertData(data);
};
// we'll dedicate a whole post to this one, but the gist is that it's used
// to enforce your own custom schema to the document JSON
editor.normalizeNode = (entry) => {
// do something interesting!
normalizeNode(entry);
};
// tells slate that certain nodes don't have any text content (they're _void_)
// super handy for stuff like images and diagrams
editor.isVoid = (element) => {
if (element.type === 'image') {
return true;
}
return isVoid(element);
};
// tells slate that certain nodes are inline and should flow with text, like
// the link in our example above
editor.isInline = (element) => {
if (element.type === 'link') {
return true;
}
return isInline(element);
};
return editor;
}
By convention, their names start with
with
. They take a Slate editor, override whatever functions they need to override and return the modified editor back. Very often, they handle a few cases in some of these functions and fall back to the default behavior for the rest. Spoiler alert: about 80% of adding functionality to a Slate editor is doing string matching in these plugin functions and then manipulate the document using Slate's rich API.There are more functions you can override than the ones shown above, but these are the most common by far. You can read all about plugins in Slate's documentation.
One of the most powerful parts of Slate plugins is that they can be composed together. Each plugin can look for the things it cares about and pass everything else along unmodified. Then you can compose multiple plugins together and get an even more powerful editor:
function MyEditor() {
const editor = useMemo(() => withReact(withDragAndDrop(withMarkdownShortcuts(withEmojis(withReact(createEditor())))), []);
...
}
Like many React input components, the
<Editable>
component has a bunch of events to which you can listen. We don't have time to go through them all here, but we'll mention the one we use most often: onKeyDown()
By handling this event, we can do all sorts of powerful things in our editor, like adding hotkeys for example:
<Editable
onKeyDown={(e) => {
// let's make the current text bold if the user holds command and hits "b"
if (e.metaKey && e.key === 'b') {
e.preventDefault();
Editor.addMark(editor, 'bold', true);
}
}}
...
/>
We use key down events everywhere in Kitemaker:
Slate has no opinions on how your blocks and inlines should look on the screen. By default, it just shoves all blocks into plain
<div>
elements and all inlines into plain <span>
elements, but that's pretty dull.To override Slate's default behavior, all we need to do is pass a function into the
<Editable>
component's renderElement
property:<Editable
renderElement={({ element, attributes, children }) => {
switch (element.type) {
case 'code':
return <pre {...attributes}>{children}</pre>;
case 'link':
return <a href={element.url} {...attributes}>{children}</a>;
default:
return <div {...attributes}>{children}</div>;
}
}}
/>
All this code is doing is looking for the
type
property on a node and picking a different rendering path based on that. Remember, as we said before, Slate doesn't care about these properties, only we do. So while the convention is to use type
to denote the type of a node, nothing is forcing you to do so. You can also add all sorts of other properties to your components that help with the rendering (like the url property we saw on links above).The things returned from
renderElement
just need to be React components. What they look like and their complexity is entirely up to you. Here we're returning a simple <pre>
element to denote a code block, but nothing is stopping us from returning a full blown <Code>
component that supports syntax highlighting (like we do in Kitemaker).There's only one important thing to remember when implementing your own rendering - always spread the
attributes
parameter as properties on the topmost component you're returning. If you don't, Slate won't be able to do its own internal bookkeeping and things will go very badly for you.This has been a super quick introduction to custom rendering, so don't worry if you don't fully grasp it yet.
You've now seen some of the basics of Slate, so you're ready to start experimenting. We thought we'd warn you a little about some of the pitfalls and tricky parts of working with Slate so you'll see them coming and not get discouraged:
useHistory()
that provides this functionality. However, we've found that this doesn't provide the exact user experience we're looking for and so we've had to extend it ourselvesWe'll cover some of these advanced topics in our subsequent posts.
While we've been very happy with Slate so far, there are a few warnings that any team embarking on building their own editor should be aware of:
We hope this served as a nice high level introduction to Slate for you and gave you some of the information you need about whether or not to give Slate a try. There is way way way too much material to cover in a single post, but there will be a number of posts that follow that dig into some of the more complex topics.
Thanks for reading! Did you find this article useful? If you want to see more material like this follow @ksimons and @KitemakerHQ on Twitter.
Kevin Simons is the CTO of Kitemaker, the super-fast issue tracker that gives distributed teams superpowers
Previously published at https://blog.kitemaker.co/building-a-rich-text-editor-in-react-with-slatejs