Dragging a paragraph to a new position feels effortless to the writer. Building it is less so. The browser fires a set of drag events, the editor has to decide where the drop belongs in the document tree, and the content has to move without losing its formatting, its images or the selection. Each step has a trap, and most of them only show up with a long document or a nested list.
Find the drop position in the document, not on the screen
The dragoverevent gives the pointer position in the viewport, and that is not a document position. The drop should go before or after the block under the pointer, depending on whether the pointer is in its top or bottom half. Map the pointer to a document position with the editor’s coordinate API, climb to the nearest block, and decide before or after from the block’s midpoint:
function dropTarget(view: EditorView, event: DragEvent) {
const hit = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!hit) return null;
const $pos = view.state.doc.resolve(hit.pos);
const block = $pos.depth > 0 ? $pos.before(1) : hit.pos;
const dom = view.nodeDOM(block) as HTMLElement | null;
if (!dom) return null;
const rect = dom.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
return { pos: after ? block + view.state.doc.nodeAt(block)!.nodeSize : block };
}Test the position in both halves of a tall block, not just near its edges. A drop that lands one block too high or low is the most common complaint, and it is usually caused by this one comparison.
Move the content, and do not duplicate it
A drag inside the same editor should move the content. Delete the source and insert the same slice at the target in one transaction, so undo sees a single step. Compute both positions against the original document. Applying the deletion first can shift the target, and applying the insertion first can shift the source. Let the transaction mapping handle the shift, or apply the changes in the order that keeps both positions valid.
Dragging a block into one of its own children breaks most often. Moving a list item inside itself would create a loop in the tree. Check that the target is not inside the dragged range, and refuse the drop when it is.
Set a private data type for internal drags
The drag source must set data on the event’s dataTransfer, or some browsers will not start a drag at all. Use a private type, so an internal drag is not mistaken for text copied from another page:
function onDragStart(event: DragEvent, slice: string) {
event.dataTransfer?.setData("application/x-editor-slice", slice);
event.dataTransfer!.effectAllowed = "move";
}
function isInternalDrag(event: DragEvent) {
return event.dataTransfer?.types.includes("application/x-editor-slice") ?? false;
}The drop handler checks for the private type first. If it is present, it moves the slice. If it is absent, the drop came from outside, and the content should go through the same cleaning path as a paste, so unsafe markup from another page does not enter the document.
Touch devices have no drag events
Mobile browsers do not fire the usual drag events for a finger moving over text, so drag and drop is a desktop feature unless you build a separate gesture. Offer a menu action that moves a block up or down on touch screens, and keep the drag for pointer devices. Do not hide the feature behind a long press that users cannot discover.
A short test list
- Drag a paragraph to the top half and the bottom half of another paragraph. It should land before and after.
- Drag a list item onto its own child. The drop should be refused.
- Drag a block and press undo. The document should return to its previous order in one step.
- Drag text from another web page into the editor, then check that its styles are cleaned.
- Drag a block near the end of a long document with the page scrolled, and confirm the drop position is still correct.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.