Engineering

Drag and drop inside a rich text editor

Dragging a paragraph is a document-position problem, not a screen-position one. Pick the drop point in the tree, move rather than copy, and refuse drops into a block's own children.

7 min read

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:

Code example
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:

Code example
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


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.