In the previous, we talked about setting up a simple SlateJS text editor. Now, we’re going to add two new features to our text editor — inserting an image and link.
Toolbar
For us to start adding rich text functionality, we’ll need to create a toolbar component.
import { useSlateStatic } from 'slate-react';
...
const Toolbar = () => {
const editor = useSlateStatic()
const handleInsertImage = () => {
const url = prompt("Enter an Image URL"); // For simplicity
insertImage(editor, url); // will be implemented later
};
const handleInsertLink = () => {
const url = prompt("Enter a URL"); // For simplicity
insertLink(editor, url); // will be implemented later
};
return (
<div className="toolbar">
<button onClick={handleInsertImage}>Image</button>
<button onClick={handleInsertLink}>Link</button>
</div>
)
}
Important things to note here are
-
useSlateStatic
: gives us an instance of our Editor which won’t cause a re-render, -
insertImage
: a helper function that will insert an image into our Editor -
insertLink
: a helper function that will insert a link into our Editor
We can then use this component as a child of our Slate
component. We do this so we can use useSlateStatic
.
const Editor = () => {
...
return (
<Slate ...>
<Toolbar />
...
</Slate>
)
}
insertImage
Our insertImage
function will handle how we’ll insert images into our Editor. We’ll have to set some rules.
- If the Editor isn’t focused, we’ll add the image at the end of the Editor.
- If the Editor is focused on an empty Node or void Node (eg. image node), we’ll replace the empty Node node with the image.
- If the Editor is focused on a non-empty Node, we’ll add the image after the Node.
const insertImage(editor, url) => {
if (!url) return;
const { selection } = editor;
const image = createImageNode("Image", url);
ReactEditor.focus(editor);
if (!!selection) {
const [parentNode, parentPath] = Editor.parent(
editor,
selection.focus?.path
);
if (editor.isVoid(parentNode) || Node.string(parentNode).length) {
// Insert the new image node after the void node or a node with content
Transforms.insertNodes(editor, image, {
at: Path.next(parentPath),
select: true
});
} else {
// If the node is empty, replace it instead
Transforms.removeNodes(editor, { at: parentPath });
Transforms.insertNodes(editor, image, { at: parentPath, select: true });
}
} else {
// Insert the new image node at the bottom of the Editor when selection
// is falsey
Transforms.insertNodes(editor, image, { select: true });
}
}
insertLink
Now that we’re able to insert images, let’s add functionality to insert links. Similar to the images, we need to set rules.
- If the Editor isn’t focused, insert the new link inside of a paragraph at the end of the Editor.
- If the Editor is focused on a void node (eg. image node), insert the new link inside of a paragraph below the void node.
- If the Editor is focused inside of a Paragraph, insert the new link at the selected spot.
- If a range of text is highlighted, convert the highlighted text into a link.
- If the selected text consists of a link, remove the link and follow Rule #3 and #4.
const createLinkNode = (href, text) => ({
type: "link",
href,
children: [{ text }]
});
const removeLink = (editor, opts = {}) => {
Transforms.unwrapNodes(editor, {
...opts,
match: (n) =>
!Editor.isEditor(n) && Element.isElement(n) && n.type === "link"
});
};
const insertLink = (editor, url) => {
if (!url) return;
const { selection } = editor;
const link = createLinkNode(url, "New Link");
ReactEditor.focus(editor);
if (!!selection) {
const [parentNode, parentPath] = Editor.parent(
editor,
selection.focus?.path
);
// Remove the Link node if we're inserting a new link node inside of another
// link.
if (parentNode.type === "link") {
removeLink(editor);
}
if (editor.isVoid(parentNode)) {
// Insert the new link after the void node
Transforms.insertNodes(editor, createParagraphNode([link]), {
at: Path.next(parentPath),
select: true
});
} else if (Range.isCollapsed(selection)) {
// Insert the new link in our last known location
Transforms.insertNodes(editor, link, { select: true });
} else {
// Wrap the currently selected range of text into a Link
Transforms.wrapNodes(editor, link, { split: true });
// Remove the highlight and move the cursor to the end of the highlight
Transforms.collapse(editor, { edge: "end" });
}
} else {
// Insert the new link node at the bottom of the Editor when selection
// is falsey
Transforms.insertNodes(editor, createParagraphNode([link]));
}
};
Custom Type Link
Now that we’re able to insert links, let’s make sure we’re able to render a Link correctly.
const Link = ({ attributes, element, children }) => (
<a {...attributes} href={element.href}>
{children}
</a>
);
We can then update our renderElement
function to include the new Link type.
const renderElement = (props) => {
switch (props.element.type) {
case "image":
return <Image {...props} />;
case "link":
return <Link {...props} />;
default:
return <Paragraph {...props} />;
}
};
Link Popup
Since we can’t really tell what URL the link has, we can create a simple popup whenever we focus on a link. We can do that by updating our Link
component.
const Link = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const selected = useSelected();
const focused = useFocused();
return (
<div className="element-link">
<a {...attributes} href={element.href}>
{children}
</a>
{selected && focused && (
<div className="popup" contentEditable={false}>
<a href={element.href} rel="noreferrer" target="_blank">
<FontAwesomeIcon icon={faExternalLinkAlt} />
{element.href}
</a>
<button onClick={() => removeLink(editor)}>
<FontAwesomeIcon icon={faUnlink} />
</button>
</div>
)}
</div>
);
};
Conclusion
With our rich text taking shape, we’re starting to see the power of Slate and how you have the power to implement the way you want to.