Dev Log: Custom drag and drop in ProseMirror
I spent a week trying to implement custom drag and drop in ProseMirror (using Tiptap). I tried three different libraries. None of them worked perfectly. Here’s what I learned so you don’t have to suffer like I did.
The Challenge
ProseMirror has its own drag and drop system. It’s great for text editing - you can drag paragraphs around, reorder lists, that sort of thing. But I needed to drag custom components from outside the editor into it.
Sounds simple, right? Wrong.
Attempt 1: dndkit
Started here because the API looked clean and it’s popular in the React community.
import { DndContext, DragOverlay } from '@dnd-kit/core'
// This worked great... until it didn't
The Good:
- Beautiful API
- Smooth animations
- Great React integration
The Deal Breaker:
- Doesn’t support HTML5 drag and drop backend
- Can’t do cross-window dragging
- Iframe drop areas? Forget it
ProseMirror renders in an iframe in my app. Dead on arrival.
Attempt 2: react-dnd
Okay, let’s go with the OG drag and drop library.
import { useDrag, useDrop, DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
// So much promise, so much pain
The Good:
- Uses native HTML5 drag and drop
- Mature, battle-tested
- Tons of examples
The Deal Breaker:
- Can’t modify the DataTransfer object
- ProseMirror’s drop handler never fires
- The DndProvider conflicts with ProseMirror’s internal drag handling
I spent two days trying to make these two play nice. They refused.
Attempt 3: Pragmatic Drag and Drop (Atlassian)
Last resort - use what the Atlassian team built for Confluence.
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
// Low level, but at least it works
The Good:
- Works with ProseMirror!
- Exposes raw browser events
- No provider conflicts
- Used in production by Confluence (which uses ProseMirror)
The Painful:
- Documentation assumes you know browser DnD inside out
- Way more boilerplate
- You’re basically writing your own drag and drop library
What Actually Works
Here’s the approach that finally worked:
- Use Pragmatic DnD for the drag source
- Let ProseMirror handle the drop
- Pass data through the DataTransfer API
- Parse it in ProseMirror’s handleDrop
// In your drag source
draggable({
element: dragRef.current,
getInitialData: () => ({
type: 'my-component',
data: componentData
}),
})
// In your ProseMirror config
handleDrop: (view, event, slice, moved) => {
const data = event.dataTransfer.getData('application/json')
// Parse and insert your custom node
}
Lessons Learned
- ProseMirror is possessive - It really wants to own all the drag and drop in its editor
- HTML5 DnD is weird - The API is old and full of quirks
- Libraries make trade-offs - Pretty APIs often mean less control
- Read the source - Sometimes you need to dive into ProseMirror’s source to understand what’s happening
The Compromise
I ended up with a hybrid approach:
- Custom drag sources outside the editor using Pragmatic DnD
- Native ProseMirror drag and drop inside the editor
- A translation layer between them
It’s not perfect, but it works.
Would I Do It Again?
Honestly? I’d probably try to avoid custom drag and drop with ProseMirror altogether. Maybe use a button to insert components, or a slash command menu.
But if you absolutely need it, start with Pragmatic DnD and expect to write more code than you’d like.
Sometimes the best solution is to change the problem.
Update: If anyone has cracked this properly, please let me know. I’m still looking for a better way.