DEV Community

Karl Castillo
Karl Castillo

Posted on • Updated on

SlateJS: Adding Images and Links

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

insertImage

Our insertImage function will handle how we'll insert images into our Editor. We'll have to set some rules.

  1. If the Editor isn't focused, we'll add the image at the end of the Editor.
  2. 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.
  3. 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. If the Editor isn't focused, insert the new link inside of a paragraph at the end of the Editor.
  2. If the Editor is focused on a void node (eg. image node), insert the new link inside of a paragraph below the void node.
  3. If the Editor is focused inside of a Paragraph, insert the new link at the selected spot.
  4. If a range of text is highlighted, convert the highlighted text into a link.
  5. 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]));
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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} />;
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Demo

Discussion (7)

Collapse
phongluudn1997 profile image
phongluudn1997

Thank you, you save my life.
Btw, Range.isCollapsed(selection) should replace to selection.isCollapsed according to the latest version of SlateJS

Collapse
viktorb98611515 profile image
Viktor Borisov • Edited on

Thank you very useful!
Do you have an idea how to have a link from image ?
Having a node with structure like this, does not work for me:
{
type: 'link',
url: 'google.com',
children: [{type:'image', url: 'srcOfImg', children:[]}]
}
When I insert it the 'image' child is removed.

Collapse
ayeprahman profile image
Arif Rahman

Nice article! I wonder is there a way to upload local images instead or pasting a link?

Collapse
mahadevans87 profile image
Mahadevan Sreenivasan

Hey cool tutorial.

Is it possible to resize an image?

Collapse
koralarts profile image
Karl Castillo Author

At this point, you cannot resize the image but that could be another feature that I can think about adding to the editor.

Collapse
masumluf profile image
Md.Rezaul Karim

How Can I add a paragraph after uploading an image ? So user can easily type anything after the image.

Collapse
cormacncheese profile image
Cormac

Super helpful, but createParagraphNode and Element.isElement are giving me type errors.

@phongluudn1997's comment solved the third error.