What’s the best way of getting user input into your application? This isn’t an easy question. Maybe we should try to answer this by looking at the type of input you want to get? Often, it is well-structured and relatively static. In these cases forms are ideal as they let you specifically state what you need from the user and then save it in a server-friendly format. Other times, however, this input is much more dynamic. A simple case of this are blog comments: where <textarea />
leaves a lot to be desired from a UX point of view. You could always set the component to contentEditable="true"
but I could write a whole essay on why contentEditable is broken (I just might one day…). After extensive testing though I can confirm that it’s the best way to go for dynamic, rich user input. Dealing with contentEditable
directly though is… horrible. Luckily, there are frameworks out there that take much of the hassle out of using contentEditable whilst keeping the things that make it good. The current best of these (IMHO) is Slate.js:
Slate is a completely customizable framework for building rich text editors.
Slate lets you build rich, intuitive editors like those in Medium, Dropbox Paper or Google Docs—which are becoming table stakes for applications on the web—without your codebase getting mired in complexity.
The pros of using Slate over, say, Draft.js or ProseMirror are many:
Slate.js has a comprehensive walkthrough for getting up and running. However, once you’ve completed that, you’re pretty much on your own. This tutorial aims to pick up where the walkthrough leaves off. Here, we’ll be:
We’ll do it all in Typescript to take advantage of Slate.js’ excellent type definitions. On top of that, there isn’t any typescript documentation for Slate as it stands, so this can also act as a primer if you want to use Slate with TS in your future projects.
I like this tutorial project because it should give you the necessary skills to build upon it to create something pretty cool!
Before we begin, this tutorial expects that you have a basic knowledge of React, ES6 and Typescript syntax (we keep the TS pretty simple here though, as it’s more for the developer experience). I would also expect that you’ve run through the Slate.js walkthrough as it gives a good overview of styling nodes and applying custom formatting — which we do not touch on here.
Slate is still in beta and has undergone a number of breaking changes over the past few months. This has stabilised somewhat since the start of 2020, but it is not possible to guarantee that this will remain the case forever until version 1.0.0. This tutorial uses Slate 0.57.1, I recommend that this is the version that you use when following along. Updates to the tutorial will be reflected here.
We’ll be using the excellent Create-React-App for initialising this project. To get started, use the following command in whatever directory you want to initialise the project in:
npx create-react-app slate-thesaurus-editor --template typescript
First, delete the logo and app.css
files, as well as our test files as we won’t be writing tests in this tutorial. Also, update app.tsx
to a display a basic Hello World
page:
import React from "react";
const App = () => {
return <div>Hello World!</div>;
};
export default App;
Test this is running correctly in development mode with npm run start
.
Assuming everything is going fine so far. We next need to install Slate and the slate-react wrapper library:
npm i slate slate-react
As the walkthrough goes over setting things up, I won’t duplicate things here. Let’s set up the basic editor components in App like so:
import React, { useMemo, useState } from "react";
import { createEditor, Node } from "slate";
import { Slate, Editable, withReact } from "slate-react";
const App: React.FC = () => {
const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = useState<Node[]>([
{
type: "paragraph",
children: [{ text: "A line of text in a paragraph." }]
}
]);
return (
<Slate
editor={editor}
value={value}
onChange={value => setValue(value)}
>
<Editable />
</Slate>
);
};
export default App;
Note that the Editor itself is made of two components: Slate
and Editable
. So far, we haven’t done anything differently to the walkthrough. That’s about to change though. First though, we should add some basic styles to the editor.
Let’s add some global css to make things a little easier and more pleasant to work with. In index.css
add the following lines:
:root { font-size: 62.5%; /* sets 1rem === 10px */ background-color: #F8FAF9; color: #262D33;}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
As mentioned in the comment, setting the global font-size to 62.5% makes 1rem === 10px
and allows us to do all of our sizing with relative units where appropriate without the need to reach for a calculator each time. Since learning this trick, I’ve never gone back.
Next, let’s add some styles to the editor itself. We’ll be using Styled Components for this so let’s add it to our application:
npm i styled-components
I find a good practice for component organisation is to place the styles with the .tsx code in a folder named after the component itself. That way you can reference the folder when importing. Rename the App.tsx
file to index.tsx
and place it in a folder called App
in your src
directory. Make a new file called styles.tsx
in the App
directory too. Your folder structure should look like this:
/src
|- /App
| |- index.tsx
| |- styles.tsx
...
In styles.tsx
lets create 2 new styled components, EditorStyles
and Container
. I want the editor to remain centred and have a width between 60 and 80 chars for readability. If this was a production app, we could spend a lot more time on this but, for simplicity’s sake, here are the styles:
import styled from "styled-components";
export const Container = styled.div`
margin: 2rem auto;
max-width: 72rem; //80chars
min-width: 54rem; //60chars
display: flex;
flex-direction: column;
`;
export const EditorStyles = styled.div`
padding: 0 0 45vh 0;
font-family: "IBM Plex Mono", monospace;
font-size: 1.5rem;
`;
IBM Plex Mono is a lovely monospaced font by the Bold Monday foundry which is freely available from the Google Fonts api. To use it just add this line to the <head>
tag of public/index.html
:
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
Next, import and use the styled components in your app component:
import React, { useMemo, useState } from "react";
import { createEditor, Node } from "slate";
import { Slate, Editable, withReact } from "slate-react";
import { Container, EditorStyles } from "./styles";
export const App: React.FC = () => { const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = useState<Node[]>([
{
type: "paragraph",
children: [{ text: "A line of text in a paragraph." }]
}
]);
return (
<Container> <EditorStyles> <Slate
editor={editor}
value={value}
onChange={value => setValue(value)}
>
<Editable />
</Slate>
</EditorStyles> </Container> );
};
Note I’ve also changed export default
to a named export to keep with Typescript best practices. Make sure you update your import statement to reflect the export change in your main index.tsx
file too:
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import { App } from "./App";import * as serviceWorker from "./serviceWorker";
ReactDOM.render(<App />, document.getElementById("root"));
That should be enough for editor styling. Let’s move on to actually writing some code…
In order to be able to display a floating menu at the location of the cursor. Our application needs to know the cursor’s position in the window. The best way I have found of doing this is outside of Slate and via the DOM through React’s useEffect
hook. We can use the Range.getBoundingClientRect()
method to get our current selection coordinates:
export const App: React.FC = () => {
/* ... */
const [cursorPosition, setCursorPosition] = useState<DOMRect | null>(null);
useEffect(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
setCursorPosition(sel.getRangeAt(0).getBoundingClientRect());
}, [value]);
// sanity check:
console.log(cursorPosition);
return (
/* ... */
);
};
Run the app. You should see a DOMRect
object in the console whenever you type and change Slate’s value
prop.
console output --> DOMRect {x: 708, y: 20, width: 0, height: 19, top: 20, …}
Let’s put this to use!
Now that we have our cursor position. We can use it to display a floating menu that hovers below the cursor as you type. Let’s start by creating a new folder to hold our Floating menu components. Create /FloatingMenu
with a blank index.tsx
and styles.tsx
inside it. We’ll want to send our cursor position to the FloatingMenu component as a prop so let’s define an interface so Typescript knows about it in the new index.tsx:
export interface IFloatingMenu {
cursorPosition: DOMRect;
}
In essence, the FloatingMenu is a modal window as by definition, it floats over the editor window. It would make sense to render this outside of the normal DOM hierarchy for accessibility best practices. You can achieve this easily with ReactDOM.createPortal
and is what we do here. For now, let’s create a basic modal component to test our positioning:
import React from "react";import ReactDOM from "react-dom";import { StyledFloatingMenu } from "./styles";
export interface IFloatingMenu {
cursorPosition: DOMRect;
}
export const FloatingMenu: React.FC<IFloatingMenu> = ({ cursorPosition }) => { return ReactDOM.createPortal( <StyledFloatingMenu position={cursorPosition}> Floating Menu! </StyledFloatingMenu>, document.body );};
Next, let’s write the styles and make use of the cursorPosition
prop. Create a StyledFloatingMenu
component in styles.tsx
as follows:
import styled from "styled-components";
import { IFloatingMenu } from "./index";
export const StyledFloatingMenu = styled.div<IFloatingMenu>`
display: flex; // for future item styling
flex-direction: column; // for future item styling
padding: 0;
position: absolute;
top: ${({ position }) =>
position ? position.bottom + window.pageYOffset + 4 : -10000}px;
left: ${({ position }) =>
position ? position.left + window.pageXOffset - 5 : -10000}px;
z-index: 100;
margin-top: -0.2rem;
opacity: 0.93;
font-family: "IBM Plex Mono", monospace;
font-size: 1.5rem;
background-color: #f8faf9;
color: #585f65;
border: 1px solid #dadddf;
border-radius: 3px;
`;
The important things to note here are the top:
and left:
properties. Let’s break those down.
top: ${({ position }) =>
position ? position.bottom + window.pageYOffset + 4 : -10000}px;
${({ position }) => ...
is how to access props from inside a styled component’s style definition. We first check that the prop exists. If it does, then we actually want the menu to float below the cursor, hence the rather confusing position.bottom
in the top
definition. window.pageYOffset
takes into account any window scrolling so that the calculated position is based on the viewport rather than the window as a whole. The integer value is just to offset the text so that it appears directly below the typed text. If there is no prop then we display this well offscreen. In fact, we will be conditionally rendering the component in React so this is an extra fallback that should never be used.
The left:
definition is identical to the above and the remainder of the styles are aesthetic only.
All that’s left is to import and conditionally render your component into App/index.tsx
:
/* ... */
import { FloatingMenu } from "../FloatingMenu";
export const App: React.FC = () => {
/* ... */
return (
<>
<Container>
<EditorStyles>
<Slate
editor={editor}
value={value}
onChange={value => setValue(value)}
>
<Editable />
</Slate>
</EditorStyles>
</Container>
{cursorPosition && <FloatingMenu cursorPosition={cursorPosition} />} </>
);
};
If you run the app now you should see this:
The second stage of this project involves getting the current word at the selection from the Slate editor. There are a lot of ways to do this, but I have found that the most robust way is to pull the word from the slate value
itself. Looking at the Slate documentation, you can see that every time a change occurs in the editor, the onChange function gets called. Let’s handle this ourselves.
Refactor the current onChange so that you can handle it outside of the render return in App/index.tsx
:
export const App: React.FC = () => {
const [currentWord, setCurrentWord] = useState("");
/* ... */
const handleChange = (value: Node[]):void => {
setValue(value);
};
/* ... */
<Slate
editor={editor}
value={value}
onChange={value => handleChange(value)}
>
/* ... */
}
We always want to keep the setValue(value)
line as this is how Slate-react updates internally on user input. Next, Import Editor
and Range
from Slate. These are interfaces through which we can make changes and get data to and from the editor. They’re also written in typescript which means that you can get a lot of information from the type def files about how they work (which is useful seeing as Slate’s current documentation is somewhat lacking!). Just add them to your slate imports like so:
import { createEditor, Node, Editor, Range } from "slate";
Now let’s finish our handleChange
function, we’ll deconstruct the code afterwards:
const handleChange = (value: Node[]):void => {
setValue(value);
if (!editor.selection) return;
const [node] = Editor.node(editor, editor.selection);
if (!node.text) return;
const textToSelection = node.text.slice(
0,
Range.start(editor.selection).offset,
);
const allWords = node.text.split(/\W/);
const wordsToSelection = textToSelection.split(/\W/);
const currentIndex = wordsToSelection.length - 1;
setCurrentWord(allWords[currentIndex]);
};
What’s happening here? Let’s take a look.
Editor.node()
returns the Slate node as an array of [node, path]
when passed the current editor and a Slate Range (in this case the selection). We currently don’t care about the path, so we destructure the array to just the first element.
Because we have no idea where the current selection is in the node itself (the end user may have clicked halfway through a word for all we know) we want to use the current selection offset to work out where we are. But, we want to return the whole current word, not just up to the selection.
textToSelection
slices the current node’s text up to the offset of the selection start. We get this with Range.start
: a utility function that returns the ‘start’ point of a selection’s anchor
and focus
in order to always return the ‘first’ point in a selection.
Next we get allWords
: all the words in the current node as an array, splitting on word boundaries. On top of that, we do the same with textToSelection
to get all words up to the selection start.
The length of wordsToSelection should be our current word’s index plus one (given that arrays are zero indexed). It follows, therefore, that allWords[wordsToSelection.length - 1]
is the current word!
Let’s check to see if this is working by having our floating menu display the current word. First we’re going to have to update the props interface so that typescript knows that our component is going to receive a currentWord string in FloatingMenu/index.tsx
:
interface IFloatingMenu {
cursorPosition: DOMRect;
currentWord: string;}
Next, pass the currentWord and display it in the FloatingMenu
component:
export const FloatingMenu: React.FC<IFloatingMenu> = ({
cursorPosition,
currentWord
}) => {
return ReactDOM.createPortal(
<StyledFloatingMenu position={cursorPosition}>
{currentWord}
</StyledFloatingMenu>,
document.body
);
};
Then update the App
component’s FloatingMenu with the new prop:
/* ... */
return (
/* ... */
{cursorPosition && (
<FloatingMenu
cursorPosition={cursorPosition}
currentWord={currentWord} />
)}
/* ... */
);
Give it a go! You’ll see that as you type the word updates and moves with the cursor. You’ll also notice a bug: the floating menu position doesn’t seem to update when you move the selection! Why don’t you try to fix this yourself before looking at the solution below?
…
…
So. Did you see the problem?
…
…
Well, the reason the menu isn’t updating is that even though moving the selection counts as a change in Slate, it isn’t updating the value and so useEffect isn’t running. It’s actually difficult to isolate Slate’s onChange method as a dependency because it is managed internally in Slate itself and doesn’t bubble up. A quick fix for now is to set useEffect
to update when currentWord
changes rather than value.
useEffect(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
setCursorPosition(sel.getRangeAt(0).getBoundingClientRect());
}, [currentWord]);
This works well until you move the selection within the same word at which point it doesn’t update. I want to stop here for a second to think about possible solutions. We could try to find a better way of setting the dependencies so that the position is recalculated more frequently. It might be better to think about what we want to achieve from a UX point of view here first. I mean, do we actually want the floating menu to follow the cursor or would it be better if it floated below the current word’s start letter and grew as the word grew? I would argue that the latter looks and feels more natural. In that case, we would be better off calculating the position of the start of the word and displaying the floating menu there. That way you would have an idea of where your new thesaurus term is going to be inserted.
Let’s try to implement this.
Given that we are working with DOMRanges
for getBoundingClientRect
. We can get the position of the start of the word by creating a new Range
object with that word’s offset. We already know how to get our selection’s range, we just need to work out how many characters are between our selection and the start of the word to create that new range.
First, let’s get the current word offset. In App/index.tsx
add a currentWordOffset
value to state and edit the handleChange
function:
export const App: React.FC = () => {
const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = useState<Node[]>([
{
type: "paragraph",
children: [{ text: "A line of text in a paragraph." }]
}
]);
const [cursorPosition, setCursorPosition] = useState<DOMRect | null>(null);
const [currentWord, setCurrentWord] = useState("");
const [currentWordOffset, setCurrentWordOffset] = useState(0);
/* ... */
const handleChange = (value: Node[]): void => {
setValue(value);
if (!editor.selection) return;
const [node] = Editor.node(editor, editor.selection);
if (!node.text) return;
const textToSelection = node.text.slice(
0,
Range.start(editor.selection).offset
);
const allWords = node.text.split(/\W/);
const wordsToSelection = textToSelection.split(/\W/);
const currentIndex = wordsToSelection.length - 1;
const currentWordOffset = textToSelection.length - wordsToSelection[wordsToSelection.length - 1].length;
setCurrentWord(allWords[currentIndex]);
setCurrentWordOffset(currentWordOffset); };
/* ... */
}
Now that we have our offset value, we can refactor out getBoundingClientRect
Effect:
useEffect(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0).cloneRange(); range.setStart(sel.anchorNode!, currentWordOffset); range.collapse(true); setCursorPosition(range.getBoundingClientRect());}, [currentWord, currentWordOffset]);
A few notes: cloneRange()
returns a new Range object so that we can alter the start of it without affecting our current selection. If you don’t do this you end up having strange behaviour when the start of your selection changes (i.e. when dragging a selection, it keeps resetting). Other than that, we set the new range to start at the calculated offset where the current word begins and then make sure it’s collapsed before calling our getBoundingClientRect
as before. We also add currentWordOffset
to our dependency array.
With that, we’ve fixed the issues with the floating menu position and have considered the user experience whilst doing so! Give it a go and see what you think. Next, let’s make a start on the live thesaurus!
For the tutorial, I’ve decided to make the editor request synonyms for the word as you type. This is so that we can go over things like debouncing input rather than it being a good design choice! Datamuse provides a free to use (for 100,000 queries per day) word-finding engine for developers. This service is what we will be using to get a thesaurus like function for our text editor.
Looking at the Datamuse api, we’ll go with ‘means-like’ rather than strict synonyms as it should provide a more varied selection of terms. The query string will be the following: https://api.datamuse.com/words?ml=${word}&max=10
. Note that we’ve limited to max returned results to 10 for ease of display but, that number is arbitrary.
Writing our fetch request is simple. In App/index.tsx
, outside of the component body add:
import AwesomeDebouncePromise from "awesome-debounce-promise";/* ... */const searchThesaurus = (word: string) => fetch(`https://api.datamuse.com/words?ml=${word}&max=10`);
export const App: React.FC = () => {
/* ... */
}
The less simple thing is that we don’t want to be making that request every kepress, otherwise we’ll hit our limits quickly (and datamuse wouldn’t be very happy with our app firing off potentially hundreds of requests a second)! In order to stop this from happening, we need to debounce our input so that the requests are only made every so often. Debouncing an async request in react can be a bit problematic. Luckily for us though, someone has come up with a solution to this that simplifies things: Awesome Debounce Promise.
First install the package: npm i awesome-debounce-promise
Let’s edit our request to include the new package. AwesomeDebouncePromise
takes our function followed by the time between requests (in ms) as arguments:
const searchThesaurus = (word: string) =>
fetch(`https://api.datamuse.com/words?ml=${word}&max=10`);
const searchThesaurusDebounced = AwesomeDebouncePromise(searchThesaurus, 500);
I’ve found that a gap of 500ms between requests seems to work well, but you can tweak this to fit your use case if you’d like.
Finally, let’s update our handleChange
function to test this out:
export const App: React.FC = () => {
/* ... */
const handleChange = (value: Node[]): void => {
setValue(value);
if (!editor.selection) return;
const [node] = Editor.node(editor, editor.selection);
if (!node.text) return;
const textToSelection = node.text.slice(
0,
Range.start(editor.selection).offset
);
const allWords = node.text.split(/\W/);
const wordsToSelection = textToSelection.split(/\W/);
const currentIndex = wordsToSelection.length - 1;
const currentWord = allWords[currentIndex];
const currentWordOffset =
textToSelection.length -
wordsToSelection[wordsToSelection.length - 1].length;
setCurrentWord(currentWord); setCurrentWordOffset(currentWordOffset);
const results = searchThesaurusDebounced(currentWord).then(response => response.json() console.log(results); ); };
/* ... */
}
When we run our app now and select or type a new word we should get output like the following:
Given that we now know the structure of our fetched objects. We can create an interface for the apiResult
so that we can use it in our components. Define this in App/index.tsx
:
interface IApiResult {
word: string;
score: number;
tags: string[];
}
We’ll use the apiResult to populate a new state array and add the array as a prop to the FloatingMenu:
export const App: React.FC = () => {
/* ... */
const [thesaurusResults, setThesaurusResults] = useState<string[]>([]);
/* ... */
const handleChange = (value: Node[]) => {
/* ... */
searchThesaurusDebounced(currentWord) .then(response => response.json()) .then((results: IApiResult[]) => { setThesaurusResults(results.map(result => result.word)); }); };
return (
<>
/* ... */
{cursorPosition && (
<FloatingMenu
cursorPosition={cursorPosition}
currentWords={thesaurusResults} />
)}
</>
);
};
Because we’ve changed the FloatingMenu
’s props, we should reflect that change in its interface. We’ll then map through the currentWords
array and display them in the menu. Let’s also add a FloatingMenuItem component to allow for eventual mouse input and make styling easier:
import { FloatingMenuItem } from "./FloatingMenuItem";
interface FloatingMenu {
cursorPosition: DOMRect;
currentWords: string[];}
export const FloatingMenu: React.FC<FloatingMenu> = ({
cursorPosition,
currentWords}) => {
return ReactDOM.createPortal(
<StyledFloatingMenu position={cursorPosition}>
{currentWords.map((word, index) => ( <FloatingMenuItem key={index + word} onClick={() => console.log(`clicked: ${index}`)} onMouseOver={() => console.log(`mouseOver: ${index}`)} > {word} </FloatingMenuItem> ))} </StyledFloatingMenu>,
document.body
);
};
Next, add FloatingMenuItem.tsx
to your /FloatingMenu
folder, give it an interface that reflects the props it receives and export it:
import React from "react";
import { StyledFloatingMenuItem } from "./styles";
interface FloatingMenuItem {
onClick: () => void;
onMouseOver: () => void;
active?: boolean;
}
export const FloatingMenuItem: React.FC<FloatingMenuItem> = ({
active,
onClick,
onMouseOver,
children
}) => (
<StyledFloatingMenuItem
active={active}
onMouseOver={onMouseOver}
onClick={onClick}
>
{children}
</StyledFloatingMenuItem>
);
Lastly, let’s define the StyledFloatingMenuItem
component in your FloatingMenu/styles.tsx
file:
interface StyledFloatingMenuItem {
active?: boolean;
}
export const StyledFloatingMenuItem = styled.div<StyledFloatingMenuItem>`
transition: background-color 0.05s;
margin: 0;
padding: 0.2rem 0.4rem;
border: none;
text-align: left;
font-family: "IBM Plex Mono", monospace;
font-size: 1.5rem;
background-color: #f8faf9;
color: #585f65;
:focus {
outline: 0;
}
:active {
outline: none;
border: none;
color: #f8faf9;
background-color: #71787e;
}
${props =>
props.active &&
`
background-color: #DADDDF;
font-weight: bold;
`}
`;
Styled components lets us take advantage of props to specify conditional rendering of css. In this case we can change the look of the item with the active
prop. Now when we run the app, we should see the content of the menu change as we type like this:
You may have noticed a bug whilst testing though. If you move the cursor, the previous values remain in the menu whilst the request is made and before the new promise resolves. We can fix this by adding a loadingResults
state and then only rendering our menu when this is false:
export const App: React.FC = () => {
/* ... */
const [loadingResults, setLoadingResults] = useState(false);
/* ... */
const handleChange = async (value: Node[]) => {
/* ... */
setLoadingResults(true); await searchThesaurusDebounced(currentWord)
.then(response => response.json())
.then((results: IApiResult[]) => {
setThesaurusResults(results.map(result => result.word));
setLoadingResults(false); });
};
return (
<>
/* ... */
{!loadingResults && cursorPosition && ( <FloatingMenu
cursorPosition={cursorPosition}
currentWords={thesaurusResults}
/>
)}
</>
);
};
When we run the app now you’ll see that the floating menu only displays after the promise resolves.
We’re finally ready to start programmatically changing the contents of the Slate editor. Slate has a number of Interfaces that allow us to easily make changes to the editor. The first thing that we should do is refactor our existing offset code to take advantage of some of these. The Slate Range
interface looks like this:
export interface Range {
anchor: Point;
focus: Point;
[key: string]: any;
}
A Point
in turn, is defined like this:
export interface Point {
path: Path;
offset: number;
[key: string]: any;
}
This means that any object we create can function as a Slate Range so long as it has defined anchor
and focus
properties and that they are both of type Point
. We can, therefore, combine our word with our range and then only have to deal with a single object in our application. Let’s do this now. In App/index.tsx
add Range
to the Slate imports:
import { createEditor, Node, Editor, Range } from "slate";
We’ll next remove the currentWordOffset
and currentWord
state values and replace them with a currentWordRange
state.
Our currentWordRange will be a Slate Range object. As mentioned, this can be any object that meets the interface’s criteria. We’ll use this:
{
anchor: {
path: editor.selection.anchor.path,
offset: currentWordOffset
},
focus: {
path: editor.selection.focus.path,
offset: currentWordOffset + currentWord.length
},
word: currentWord
}
We will also need to refactor our useEffect
function to use the new Range object:
useEffect(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0).cloneRange();
const offset = currentWordRange ? currentWordRange.anchor.offset : 0; range.setStart(sel.anchorNode!, offset);
range.collapse(true);
setCursorPosition(range.getBoundingClientRect());
}, [currentWordRange]);
Because we’ve added out current word as a property of the Range, we can use this to stop unnecessary api calls in our function like so:
if (!currentWordRange || currentWord === currentWordRange.word) return;
Finally, we should pull our word update logic out of the handleChange
function so that we can perform this without needing to update the value state if there are no changes required. The refactored code should now look like this:
export const App: React.FC = () => {
const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = useState<Node[]>([
{
type: "paragraph",
children: [{ text: "A line of text in a paragraph." }]
}
]);
const [cursorPosition, setCursorPosition] = useState<DOMRect | null>(null);
const [currentWordRange, setCurrentWordRange] = useState<Range | null>(null); const [loadingResults, setLoadingResults] = useState(false);
const [thesaurusResults, setThesaurusResults] = useState<string[]>([]);
useEffect(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0).cloneRange();
const offset = currentWordRange ? currentWordRange.anchor.offset : 0; range.setStart(sel.anchorNode!, offset);
range.collapse(true);
setCursorPosition(range.getBoundingClientRect());
}, [currentWordRange]);
function updateCurrentWord() {
if (!editor.selection) return;
const [node] = Editor.node(editor, editor.selection);
if (!node.text) return;
const textToSelection = node.text.slice(
0,
Range.start(editor.selection).offset
);
const allWords = node.text.split(/\W/);
const wordsToSelection = textToSelection.split(/\W/);
const currentIndex = wordsToSelection.length - 1;
const currentWord = allWords[currentIndex];
const currentWordOffset =
textToSelection.length -
wordsToSelection[wordsToSelection.length - 1].length;
setCurrentWordRange({ anchor: { path: editor.selection.anchor.path, offset: currentWordOffset }, focus: { path: editor.selection.focus.path, offset: currentWordOffset + currentWord.length }, word: currentWord }); // don't make a request if the word hasn't changed! if (!currentWordRange || currentWord === currentWordRange.word) return; setLoadingResults(true);
searchThesaurusDebounced(currentWord)
.then(response => response.json())
.then((results: apiResult[]) => {
setThesaurusResults(results.map(result => result.word));
setLoadingResults(false);
});
}
const handleChange = (value: Node[]) => {
setValue(value);
updateCurrentWord();
};
/* ... */
}
Now that some refactoring is out of the way. Let’s start using our code to edit Slate. Slate has a Transforms
interface that is the basis for all programmatic changes to the editor.
Let’s add Transforms
as an import to Slate. We should also import ReactEditor
to slate-react as well as it allows us to perform some DOM manipulations later:
import { createEditor, Node, Editor, Range, Transforms } from "slate";
import { Slate, Editable, withReact, ReactEditor } from "slate-react";
Again, because it is written in typescript, the type definitions provide a lot of information and is worth checking out when you’re writing your own logic. For our purposes, we’re going to be using Transforms.insertText
and Transforms.select
. InsertText takes the current editor, the text to insert and then an options object as its parameters. The options object is very powerful way of modifying the transform itself but is currently very poorly documented. I have found the safest way to specify where you want the transform to take place is to pass a Range to the at:
property of the options object. Despite these issues, you can get an idea of just how powerful Slate can be with just a few commands. For example, replacing the word is a single line:
const handleInsertWord = (index: number) => {
if (!currentWordRange) throw new Error("No Range found!");
Transforms.insertText(editor, thesaurusResults[index], {
at: currentWordRange
});
}
Our actual handleInsertWord
function is a little bit more complicated though:
const handleInsertWord = (index: number) => {
if (!currentWordRange) throw new Error("No Range found!");
const stringToInsert = thesaurusResults[index];
// replace the word with the newly selected one:
Transforms.insertText(editor, stringToInsert, {
at: currentWordRange
});
// set the selection to be at the end of the newly inserted word.
Transforms.select(editor, {
path: currentWordRange.anchor.path,
offset: currentWordRange.anchor.offset + stringToInsert.length
});
// focus back on the editor
ReactEditor.focus(editor);
// update the current word to reflect the change
updateCurrentWord();
};
As well as replacing the word, we also need to update the selection to the end of our newly inserted word. We do this with Transforms.select
which takes the editor and then either a Point, Range or Location object (again, these are defined in the type definitions). Finally, because we want to be able to click the word and have it update in the editor, we should also pull our app’s focus to the editor when the word is inserted. To do this with Slate, we need to use the ReactEditor
interface. Luckily, it’s just a case of telling it to focus on the editor of choice though.
The last thing we need to do is set up click handlers for the FloatingMenu. In FloatingMenu/index.tsx
:
import React from "react";
import ReactDOM from "react-dom";
import { StyledFloatingMenu } from "./styles";
import { FloatingMenuItem } from "./FloatingMenuItem";
interface FloatingMenu {
cursorPosition: DOMRect;
currentWords: string[];
onClick: (index: number) => void;}
export const FloatingMenu: React.FC<FloatingMenu> = ({
cursorPosition,
currentWords,
onClick}) => {
return ReactDOM.createPortal(
<StyledFloatingMenu position={cursorPosition}>
{currentWords.map((word, index) => (
<FloatingMenuItem
key={index + word}
onClick={() => onClick(index)} onMouseOver={() => console.log(`mouseOver: ${index}`)}
>
{word}
</FloatingMenuItem>
))}
</StyledFloatingMenu>,
document.body
);
};
and in App/index.tsx
:
export const App: React.FC = () => {
/* ... */
return (
<>
/* ... */
{!loadingResults && cursorPosition && (
<FloatingMenu
cursorPosition={cursorPosition}
currentWords={thesaurusResults}
onClick={index => handleInsertWord(index)} />
)}
</>
);
};
Now when you run the application, you’ll be able to replace your current word with an option from the floating menu by clicking!
We’re now on the final stretch. We can change the word with a click but what about keyboard commands? For this we need to add an onKeyDown
function to the editor. Note that onKeyDown
lives in the <Editable />
component, not <Slate />
. We’ll just log the key for now:
<Editable
onKeyDown={(e: KeyboardEvent) => console.log(e.key)}>
We want to select a word in the list by index with the arrow keys and enter. The index needs to be updated when you press the arrow keys, so we’ll do that first. Let’s create a new state to handle our index value, and a keyHandler
function (don’t forget to update the <Editable />
component to use the new function too):
/* ... */
const [menuIndex, setMenuIndex] = useState(0);
/* ... */
const handleKeyDown = (event: KeyboardEvent) => { // make sure the thesaurus is displayed first and that it contains words if (loadingResults || !thesaurusResults.length) return; switch (event.key) { case "ArrowDown": event.preventDefault(); setMenuIndex(prevState => (prevState + 1) % thesaurusResults.length); break; case "ArrowUp": event.preventDefault(); setMenuIndex(prevState => { if (prevState - 1 < 0) return thesaurusResults.length - 1; return prevState - 1; }); break; case "Enter": { event.preventDefault(); handleInsertWord(menuIndex); break; } }};
/* ... */
return (
<>
<Container>
<EditorStyles>
<Slate
editor={editor}
value={value}
onChange={value => handleChange(value)}
>
<Editable onKeyDown={(e: KeyboardEvent) => handleKeyDown(e)} /> </Slate>
</EditorStyles>
</Container>
{!loadingResults && cursorPosition && (
<FloatingMenu
cursorPosition={cursorPosition}
currentWords={thesaurusResults}
onClick={index => handleInsertWord(index)}
onMouseOver={index => setMenuIndex(index)}
menuIndex={menuIndex}
/>
)
</>
);
We now need to represent this visually in the FloatingMenu
component. Luckily, we’ve already set up our styles. We just need to set the active FloatingMenuItem
to active
based on our index value. We also need to sync up the onMouseover
Next we need to update the FloatingMenu
component. Whilst were here, let’s remove the console log commands too:
interface FloatingMenu {
cursorPosition: DOMRect;
currentWords: string[];
menuIndex: number; onClick: (index: number) => void;
onMouseOver: (index: number) => void;}
export const FloatingMenu: React.FC<FloatingMenu> = ({
cursorPosition,
currentWords,
menuIndex, onClick, onMouseOver}) => {
return ReactDOM.createPortal(
<StyledFloatingMenu position={cursorPosition}>
{currentWords.map((word, index) => (
<FloatingMenuItem
key={index + word}
onClick={() => onClick(index)}
onMouseOver={() => onMouseOver(index)} active={index === menuIndex} >
{word}
</FloatingMenuItem>
))}
</StyledFloatingMenu>,
document.body
);
};
The final thing we need to do is to reset the menuIndex
to zero when it reloads in our updateCurrentWord
function:
function updateCurrentWord() {
/* ... */
if (!currentWordRange || currentWord === currentWordRange.word) return;
setLoadingResults(true);
setMenuIndex(0); searchThesaurusDebounced(currentWord)
.then(response => response.json())
.then((results: apiResult[]) => {
setThesaurusResults(results.map(result => result.word));
setLoadingResults(false);
});
}
If you run the application now. You should be able to replace the current word with a synonym either with your mouse or with keyboard commands! You’ll see that the currently selected menu item updates with both too.
In this tutorial, I’ve covered:
Obviously, this isn’t a perfect editor by any means. I like it because it’s a potential launch point for something much more exciting though. For instance, you could try to refactor the code to achieve some of these:
Ctrl-T
for instance) rather than with the debounced input.Hopefully, this tutorial has given you the primer necessary for creating your own rich-text user experiences. Please let me know if this has been useful for you or if you’ve noticed something that can be improved, I love feedback!
(Title photo by Aaron Burden on Unsplash)