How To Build A Text Editor Like Notion | The Startup
Slash commands are a building block in Notions text editor.
Without a doubt, Notion.so is one of my favorite apps when it comes to organizing notes. I love their minimal designed text editor because it focuses your attention on the content — and not on unnecessary UI elements.
Yet it provides you a ton of powerful features that lead to a better writing experience.
For instance, the slash command is one of those features that really enhanced my writing flow. It allows you to add and style content without leaving the keyboard. Instead of clicking a UI button to add a new heading, you can just type
/h1, hit ‘Enter’, and there you go.
Another cool thing about Notion is that it is a fully web-based application. Because of that, I was interested in how it was actually built — especially the text editor. In fact, I realized that it is not that difficult as I expected it to be.
In this article, we take a closer look at how a text editor like Notion works and how we can build one ourselves with React.js.
By doing this, we will discover and learn some essential frontend skills:
- Working with the DOM and its Nodes
- Working with Event Listeners
- Advanced State Management
Here is how our application will look like in the end. In my opinion, a perfect project for your portfolio if you are new to React.
The final application!
The Theory: How Does A Notion-like Text Editor Work?
The core concept of a Notion-like text editor is what I would like to call blocks. Each time you hit ‘Enter’ on your keyboard, you create a new block. So essentially, each page consists of one or many blocks.
From a user perspective, a block contains content and has a specific styling that is associated with the block type. Types of blocks could be headings, quotes, or paragraphs. Each of those has a unique styling.
From a technical perspective, a block is a so-called
contenteditableelement. Nearly every HTML element can be turned into an editable one. You just have to add the
contenteditable="true"attribute to it. This indicates if the element should be editable by the user. If so, users can edit their content directly in the HTML document as if it would be an
The concept gets clear in an example:
Let’s say a user adds a new block with the type of Heading. This produces the following HTML output:
Since the user selected Heading as the block type, we append an
h1element to the DOM. Thus, it gets an appropriate heading styling. The user could now directly edit the content of that
h1element by placing the cursor inside it.
Now that the user can add new blocks to the page by pressing ‘Enter’, how can he/she determine the type of an added block?
In Notion, you can do this via slash commands.
The concept of slash commands is simple yet effective. Every time you type a
/inside a block, a type-ahead menu pops up right above the cursor. This menu shows you all available block types. As you write along, you filter the block types with your query. If you found the right block type, just hit ‘Enter’, and the block turns into whatever you selected.
In our application, all slash commands will be equivalent to the corresponding HTML elements. Meaning an
/h1command turns into an H1 heading and a
/pcommand turns into a paragraph, for example.
Technically, an existing block like
<h1 contenteditable="true"> </h1>would consequently turn into
<p contenteditable="true"> </p>once the user types
/pand hits ‘ Enter’.
The Practice: How Can We Rebuild It?
Now that we know how it works in theory, let’s put it into practice. First of all, we create a new React project. You can do that by using create-react-app or any other tooling.
1 —Create An Editable Page
Having the setup done, we create our first component:
The page component stores and renders all blocks we will create later on. As a start, we only provide an initial first block that we define at the very top. This block will turn into an empty paragraph element once we created our editable block component. For now, we just render a plain
Note: Since every block needs a unique ID, I created a helper function
uid()that we can use for initializing new blocks. In case you want to use the same function:
2 — Create An Editable Block
As a next step, we create an editable block component that we can render instead of the
divcontainer. For that, we have to install an external dependency called
React-Contenteditable is a package that makes working with editable elements in React really easy. It abstracts a lot of complexity for us so that we only need to pass the right props to the
ContentEditablecomponent. The component exposed by the package then handles the rest.
To install the package, run
npm i react-contenteditable
Next, we create a new file called
The editable block renders the
ContentEditablecomponent that we imported from the
react-contenteditablepackage. By passing
tagNameas props to the component, we can define what should be displayed and how it should be displayed. Remember, the
tagNamedefines the HTML element type and hence the styling.
If we were to change the block type later on (e.g. from
h1), we can simply update the
tagNameprop. Pretty easy, right?
We also added advanced state management to the block component. When the component mounts, it receives the initial HTML content and tag via props and stores them in the state. From now on, the block component fully owns the draft state. Any subsequent changes to the
tagNameprop are being ignored.
When there have been relevant updates to the
tagNamestate property, we update the page component’s state in the
componentDidUpdatelifecycle hook too. Thus, we avoid an unnecessary re-rendering of the page.
editableBlockcomponent set up, we can finally use it in our page component:
Besides using the
EditableBlockcomponent inside our
renderhook, we additionally added new methods in the page component:
updateBlockHandlerthat we already use in our block component takes care of keeping the page and block state in sync.
addBlockHandlerappends a new block and sets the focus to it.
deleteBlockHandlerremoves a block and sets the focus to the preceding block.
We pass all three methods as props to the editable block.
Note: In the
deleteBlockHandler, we use another helper function that manually sets the cursor to the end of the block content. If we would just focus the element as we did in
addBlockHandler, it would focus the element though, but with a cursor set to the very beginning of the block content. In case you want to use the same
3 — Implement A KeyDown Listener
So far, we can only add text content to our initially added block. Let’s make things a bit more interactive with our first event listener. This will allow us to add and delete blocks as we like.
editableBlock.js, we create an
onKeyDownHandlermethod that we pass on to the
As mentioned, this event listener is primarily responsible for adding and deleting blocks. But let’s break the
onKeyDownHandlerdown bit by bit:
- Regardless of what key has been pressed, store the key in the state. We need the
previousKeyto be able to detect key combinations.
- When the user pressed
/, we store a copy of the current HTML content in the state. We do that because we want to restore a clean HTML version after the user has finished the process of selecting a block type.
- When the user pressed
Enter, we prevent the default behavior (i.e. adding a new line). Instead, we create a new block by using the
addBlockmethod which we previously created in the page component.
- Because the user should still be able to add new lines somehow, we use the
previousKeystate property to detect a
Shift+Enterkey combination. If that is the case, we do not add a new block and allow the default ‘Enter’ behavior to happen.
- Lastly, we delete an empty block when the user presses
So far, we can create and delete blocks.
Now that we can create new blocks, what about slash commands and selecting different block types? We cover that in the next steps.
4 — Add A Select Menu
As mentioned earlier, when the user types a
/, we want to display a select menu. The menu shall list up all available block types. A specific block type can then be selected either by clicking on it or by typing in the matching slash command.
To implement such a select menu, we first have to add another dependency: match-sorter. This is a simple package that helps us with querying the matching block types.
To install it, run:
npm i match-sorter
Having that done, we create a new file called
selectMenu.jswith the following contents:
At the very top, we define what block types should be selectable by the user. Each type has a tag and a label: the tag defines which HTML element type shall be used and the label defines the display name inside our menu.
When the component mounts, we calculate the position of the menu on the screen inside our
renderhook. There, we use
this.props.position. This object contains the current position of the cursor since we want to display the menu right above it.
Additionally, we attach a
keyDownevent listener in the
componentDidMounthook. It takes care of storing the entered command in state and enables the user to select a block type via keyboard.
commandstate property changes, we run our imported
matchedSorterfunction to filter for matching block types. Thus, the select menu shows only block types that match with our command.
Finally, when the user hits ‘Enter’ or clicks on an entry, we execute an
onSelectmethod that we receive via props.
Admittedly, this was a lot of code at once. But if you go through it step by step, it should be understandable hopefully.
5 — Add Block Type Selection
Next, we can put things together. Since we have our select menu component in place, we can add it to
First, we import our
SelectMenucomponent and render it conditionally. We can control its visibility by our state property
selectMenuIsOpen. If we set this to true, we render the menu additionally to the
Next, we implemented the logic that defines when and where we want to show it.
We defined two methods that handle the menu’s opening and closing. When we open it, we first get the coordinates of the currently set cursor. We do this because we want to open up the menu right above the cursor. Lastly, we store this position in the state, set
selectMenuIsOpento true, and attach a click listener.
The click listener is responsible for closing the select menu once there has been another click on the screen — no matter if it was on the menu itself or outside it. Both actions should close it.
closeSelectMenuHandlermethod then is fairly simple. We just reset our previously set state properties and detach the click listener again.
Having the opening and closing handlers implemented, we also need to define when we actually want to open the menu.
From a user perspective, we want to open the menu once the user enters a
/. Therefore, it seems intuitive to add the
this.openSelectMenuHandler()call in our previously defined
onKeyDownHandlermethod, right? Because there, we already check if the user pressed the
In fact, we have to add another event listener for that. The menu should only show up once the user releases the
/key. Otherwise, the positioning of the menu would not work correctly. Hence, we add a new
keyUpevent listener for that.
Now there is only one final step to take. Remember that we pass an
onSelectfunction as a prop to the select menu component? To make the selection process work, we have to define an associated method for that in our editable block component.
Gladly, this method is rather simple. In the
tagSelectionHandler, we receive the selected block type as the
tagargument. With it, we can update our
tagNamestate property as well as restore the HTML backup, i.e. the HTML content without the entered command.
If that process has finished, we set the cursor to the editable block again and close the menu.
And finally, believe it or not… we are done 🎉
Note: I again used a helper function for obtaining the cursor coordinates. If you want to use the same function, here it is:
Ideas For Further Development
Now that we have the core functionality of Notion’s text editor rebuilt, we have a perfect foundation for further development. There are many features that we can add to our application to improve it further.
I always like to look for inspiration in existing apps and see if I can build their features on my own. Same with Notion. Here are two concrete examples:
Context Menu Based On Selection
So far, we only show a select menu when the user types a
/. But what if the user wants to quickly edit the type of an existing block? Or he/she wants to remove a block?
Context Menu that will pop up once the user selects content
Here, a context menu comes in handy. Whenever the user selects the block’s content, we display a menu with two options: either turn the existing block’s type into another or delete the block itself with a single click.
Rearrange Blocks By Drag and Drop
Because we have an underlying block structure, we can rearrange our content fairly easily. Maybe a user realizes that this one paragraph should be further at the top of the page. In Notion, you can easily reorder blocks with a smooth drag and drop functionality. And we can do that too!
Drag And Drop functionality to reorder blocks
Admittedly, we covered a lot in this article… but I think the idea and process of building a Notion clone are really cool and exciting, especially for React beginners. Therefore, I wanted to provide you all the things you need.
You find a working example for the final application on CodeSandbox. It also includes all of the CSS stylings which we did not cover in this article.
Moreover, if you want to enhance your application with more features (e.g. features like those I mentioned in the last section), have a look at my more advanced application. It might be a good source of inspiration too.
This advanced Notion Clone also includes a backend part that persists the user content on a server. Great if you want to get in touch with basic CRUD operations in Node/Express!
Live Demo: https://notion-clone.kmuenster.com/
Github Repository: https://github.com/konstantinmuenster/notion-clone
Working With The Cursor In The Browser
As you noticed, I used some helper functions in our application. These functions primarily help us to work with the cursor in the browser, e.g. obtaining the cursor coordinates.
Since this was something I struggled with a lot during development, I recently published an article about it. In case you want to dive deeper into this topic, here it is:
As always, thanks for reading! If you stumbled with any questions or issues throughout this article, let me know in the comment section. Thus, I can update and improve the article if needed. I appreciate any feedback!