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.
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 simplediv
orspan
element with applied properties thatrenderElement
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} />
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;
}
- Consequently slate value is a tree.
- All nodes that present in value are rendered by the
Editable
component. We can specify therenderElement
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 documentbody
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>
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
intoDndContext
andSortableContext
- Adjust
renderElement
function only for top level elements. We will renderSortableElement
component withuseSortable
hook inside. - For
DndOverlay
createDndOverlayContent
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>
);
};
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)
This is great Thanks
Thank you!