DEV Community

devterminal
devterminal

Posted on • Edited on

Slate.js + dnd-kit = 🔥. Improving Rich Text Editor UX by adding Drag and Drop.

Sometimes writing experience may be improved by having the possibility to reorder content blocks. Tools like notion and note taking apps have set this trend. I, personally, like this feature especially when I work with lists.

Drag and Drop preview

In this post I want to share an idea on how to inject dnd-kit toolkit into Rich Text Editor based on slate.js.

Code that you will see in examples here doesn't work as it is. It just illustrates the main ideas. However, working examples in codesandbox are provided.

I tried to keep only necessary information without getting deep into details.

Let's start!

slate.js

Slate.js is a great framework for building your own rich text editors. You can read about use cases on their documentation page: https://docs.slatejs.org/.

For the really first and simple version of editor we need the following components: Slate, Editable and DefaultElement.

  • Slate component is more like a react context that provides value, editor instance and some other useful things.
  • Editable component renders all nodes and provides right properties to them.
  • DefaultElement is a simple div or span element with applied properties that renderElement receives (attributes, children, element).

The following code should explain how it works in just a few lines of code:



const App = () => 
  <Slate value={value}>
    <Editable renderElement={renderElement} />
  </Slate>

const renderElement = ({ attributes, children, element }) =>
  <DefaultElement attributes={attributes} children={children} element={element} />


Enter fullscreen mode Exit fullscreen mode

sandbox: https://codesandbox.io/s/slate-kv6g4u

deps: yarn add slate slate-react

I want to share some details about how slate works that are important for current topic:

  • Slate value has Descendant[] type.
  • type Descendant = Element | Text.
  • Element ```typescript

interface Element {
children: Descendant[];

}

- `Text`
```typescript


interface Text {
  text: string;  
}


Enter fullscreen mode Exit fullscreen mode
  • Consequently slate value is a tree.
  • All nodes that present in value are rendered by the Editable component. We can specify the renderElement function to define each element's appearance.

Good start, let's continue with exploring dnd-kit.

dnd-kit

This toolkit is really useful for building Drag and Drop interfaces. It provides nice primitives to build your own dnd logic maximum customizable way. You can find all information here: https://dndkit.com/

Few words about how it is supposed to be applied in the app. It provides the following API:

  • DndContext
  • useDraggable
  • useDroppable

We can wrap dnd area into DndContext, then inside this area apply useDraggable hook to draggable elements and useDroppable hook to droppable elements.

But we won't use it this way for sorting because dnd-kit already provides higher level API for it:

  • SortableContext
  • useSortable

One more component we need is:

  • DragOverlay. This component will be rendered on document body level and next to the mouse cursor temporarily while dragging.

Let's show how we can use it. This example is intended to demonstrate how dnd-kit works itself, without slate.js. You can see how components related to each other:



const App = () => 
  <DndContext>
    <SortableContext>
      {items.map(item => <SortableItem item={item} />)}
      {createPortal(
        <DragOverlay>
          {activeItem && renderItemContent({ item: activeItem })}
        </DragOverlay>,  
        document.body  
      )}
    </SortableContext>
  </DndContext>

const SortableItem = ({ item }) => {
  const sortable = useSortable({ id: item.id });

  return <Sortable sortable={sortable}>
    <button {...sortable.listeners}>â ¿</button>
    {renderItemContent({ item })}
  </Sortable>
}

const renderItemContent = ({ item }) => <div>{item.value}</div>


Enter fullscreen mode Exit fullscreen mode

sandbox: https://codesandbox.io/s/dnd-kit-4rs8rz

deps: yarn add @dnd-kit/core @dnd-kit/sortable

You might notice, there is a Sortable component I didn't mention before. It is a simple component that applies sortable props to div. The props like transition and transform. You can find its implementation in the sandbox.

There is also a button component that we use like a dnd handle by applying listeners to it.

slate.js + dnd-kit

I hope after the previous parts you become a bit more familiar with these libraries in case you haven't used them before. It's time to combine them.

Generally we need to do the following steps:

  • Wrap Editable into DndContext and SortableContext
  • Adjust renderElement function only for top level elements. We will render SortableElement component with useSortable hook inside.
  • For DndOverlay create DndOverlayContent component with temporary slate editor, that renders just one dragging element.

The code is here:



const App = () => {
  const renderElement = useCallback((props) => {
    return isTopLevel
           ? <SortableElement {...props} renderElement={renderElementContent} />
           : renderElementContent(props);
  }, []);

  return <Slate value={value}>
    <DndContext>
      <SortableContext>
        <Editable renderElement={renderElement} />
        {createPortal(
          <DragOverlay>
            {activeElement && <DragOverlayContent element={activeElement} />}
          </DragOverlay>,  
          document.body  
        )}
      </SortableContext>
    </DndContext>
  </Slate>
}

const SortableElement = ({
  attributes,
  element,
  children,
  renderElement
}) => {
  const sortable = useSortable({ id: element.id });

  return (
    <div {...attributes}>
      <Sortable sortable={sortable}>
        <button contentEditable={false} {...sortable.listeners}>
          â ¿
        </button>
        <div>{renderElement({ element, children })}</div>
      </Sortable>
    </div>
  );
};

const renderElementContent = (props) => <DefaultElement {...props} />;

const DragOverlayContent = ({ element }) => {
  const editor = useEditor();
  const [value] = useState([JSON.parse(JSON.stringify(element))]); // clone

  return (
    <Slate editor={editor} value={value}>
      <Editable readOnly={true} renderElement={renderElementContent} />
    </Slate>
  );
};


Enter fullscreen mode Exit fullscreen mode

sandbox: https://codesandbox.io/s/slate-dnd-kit-brld4z

deps: yarn add nanoid slate slate-react @dnd-kit/core @dnd-kit/sortable

styled example: https://codesandbox.io/s/slate-dnd-kit-styled-7qjxm3

Assigning ids to new nodes

This is necessary to have unique ids for each sorting element. We pass an array of ids into SortableContext with items prop. And we also pass an id for each element to useSortable hook.
Creating new elements is a process that slate does by itself. For example, when the Enter key is pressed. However, we can add a plugin that assigns unique ids for new elements. You can find the withNodeId plugin in the sandbox above.

Performance

I recommend checking if the performance of this solution is suitable for you. If the editor renders many elements it could be slow. If you want to prevent it, then you can try to use html5 dnd instead, for example, react-dnd library. Or you can try to adjust this solution to use partial virtualization technique. Just render the Sortable wrapper only for elements that are in viewport.

Here you can find some words about HTML5 drag and drop https://docs.dndkit.com/#architecture

Last part

As I said before this post is intended to share an idea. It might require way more coding to fix all possible issues and make it work perfectly in production. However, it might be brought to a well level user experience.

I hope you find it useful. If you have any questions feel free to ask. I would also like to receive your feedback. And if you implement similar functionality the same or different way, please share it. It is really interesting for me. Thanks!

Top comments (2)

Collapse
 
biomathcode profile image
Pratik sharma

This is great Thanks

Collapse
 
devterminal profile image
devterminal

Thank you!