To put text where the user’s selection is from code (dictation, snippets, autocomplete), call tr.insertText(text) with no positions, and give block selections a textblock first.
import { NodeSelection, TextSelection, type EditorState, type Transaction } from "prosemirror-state";
import { GapCursor } from "prosemirror-gapcursor";
import { closeHistory } from "prosemirror-history";
export function insertAtSelection(state: EditorState, text: string): Transaction | null {
const tr = state.tr;
const selection = state.selection;
// A selected image or a gap cursor has no textblock to type into.
// Add one after it, so the image isn't replaced.
if ((selection instanceof NodeSelection && !selection.node.isInline) || selection instanceof GapCursor) {
const $pos = state.doc.resolve(selection.to);
const block = $pos.parent.contentMatchAt($pos.index()).defaultType?.createAndFill();
if (!block?.isTextblock) return null;
tr.insert(selection.to, block);
tr.setSelection(TextSelection.create(tr.doc, selection.to + 1));
}
tr.insertText(text); // no from/to: replace the selection itself
return closeHistory(tr).scrollIntoView();
}
// Dispatch, then close the undo step behind the insert as well.
const tr = insertAtSelection(view.state, "hello");
if (tr) {
view.dispatch(tr);
view.dispatch(closeHistory(view.state.tr));
}
In TipTap, import the same names from @tiptap/pm/state, @tiptap/pm/gapcursor and @tiptap/pm/history, and use editor.view.
Why positions break it
tr.insertText(text, from, to) replaces the range, then keeps the old selection mapped through the change. It only collapses the selection when the selection ends exactly where the new text ends. So with from = selection.from, to = selection.to:
- Select-all (
AllSelection) maps to anotherAllSelection. The first insert replaces the document, and so does every insert after it. - A selected image (
NodeSelection) stays selected. Each insert atselection.tolands right after the image, before the previous insert, so the text comes out in reverse order. GapCursormaps throughSelection.nearand can jump into the next table cell.CellSelectiongets one range across several cells and mangles the table.
With no positions, insertText calls replaceSelectionWith. That hands the text to the selection’s own replaceWith, so each selection type (cells included) handles itself. It takes marks the way typing does (stored marks, else the marks at the cursor, else the marks across the range), and it moves the caret to the end of the new text.
Gotchas
- Quick inserts undo together.
prosemirror-historymerges changes that touch adjacent ranges withinnewGroupDelay(500 ms by default). Two inserts in a row, or an insert plus the user’s next keystroke, become one undo step.closeHistory(tr)on the insert starts a new step. AcloseHistory(view.state.tr)dispatched right after ends it, and it works even though that transaction has no steps. - Input rules don’t run. They fire on typed input, not on transactions, so inserted "- " stays text and smart quotes stay straight. That’s usually what you want for programmatic text.
- A filter can drop the transaction silently. A plugin’s
filterTransaction(TipTap’s character limit, for one) returns false and nothing happens. Compareview.state.docbefore and afterdispatchto know whether the text went in. - Don’t insert during an IME composition. If
view.composingis true, queue the text and insert it once the composition ends, or the composition breaks (Android keyboards compose every word). - Markdown round trips. With
@tiptap/markdown3.x, inline characters (*,_,[) are escaped, but a paragraph that starts with-or1.is written as-is and comes back as a list. Typing rarely produces that paragraph, since the input rule turns "- " into a list, but inserted text does.