Concepts
Position mapping
A position is an integer into the document. The moment anything changes, that integer points somewhere else. Mapping is how a position survives an edit it did not expect — and it is the single thing this editor is built around.
Why positions move
Insert five characters at the start and everything after them is five further along. The number you were holding is still a valid position; it just points at different text.
hello world XXXXXhello world
↑ ↑
7 · "world" 7 · "ello world" That is fine when you act immediately — nothing happened in between. It stops being fine the moment there is a gap: you asked a model for a rewrite two seconds ago, or a colleague's edit arrived over the wire, and the person kept typing while you waited.
Markers
ctx.mark() takes a marker. It records where the document was at that instant, and
map replays every change made since — so the position comes back pointing at the
same text rather than at the same number.
const at = ctx.mark()
const { from, to } = ctx.selection
for await (const chunk of stream) {
// Not from and to · they are two seconds old.
ctx.replace(at.mapRange({ from, to }), chunk)
} Between one chunk and the next the person may have typed, pasted, or pressed undo. The marker does not care which: it maps through whatever happened.
What it does in each case
A marker taken over "world" in "hello world", then each of these
happens, then the range is mapped:
| What happened | Document | 7–12 becomes | Result |
|---|---|---|---|
| Five characters typed before it | "XXXXXhello world" | 12–17 | still "world" |
| "hello " deleted before it | "world" | 1–6 | still "world" |
| The whole line deleted | "" | 1–1 | collapsed · it is gone |
| Typed, then undone | "hello world" | 7–12 | back where it started |
The third row is the one to write code for. When the text a marker pointed at has been
deleted, the range collapses — from and to come back
equal. Check for it, because inserting into a collapsed range is not "the rewrite landed", it
is a model's answer appearing where the user just deleted a sentence.
const target = at.mapRange({ from, to })
if (target.from === target.to) {
// What we were rewriting is gone. Drop the answer rather than paste it
// somewhere nobody asked for.
return false
} A complete example
An AI rewrite that survives whatever the user does while it streams. This is
@matrajs/ai in miniature:
const rewrite: Command<[instruction: string]> = (ctx, instruction) => {
const { from, to } = ctx.selection
if (from === to) return false // nothing selected
const at = ctx.mark() // taken now, used later
const original = ctx.doc // for the request, not for positions
void (async () => {
let received = ''
for await (const chunk of ask(instruction, original)) {
received += chunk
const target = at.mapRange({ from, to })
if (target.from === target.to) return // the text went away
editor.commands.replace(target, received)
}
})()
return true // the request started
} Note what is not here: no locking the editor, no disabling input, no "please wait". The user keeps typing and the answer still lands on the words they chose.
Where else it matters
- Collaboration · a remote step arrives in the sender's coordinates, and your unsent work has to be rebased over it. That is mapping, run in both directions.
- Comments · anchored as marks rather than as numbers, so mapping keeps them on the right words for free — and a thread does not slide up the page when somebody deletes a paragraph above it.
- Decorations · a search highlight, a remote caret, a spell-check squiggle. All positions, all mapped on every change.
- Version history · a diff between two documents is only meaningful because an old document stays exactly what it was.
Is this new?
No, and it is worth being straight about that. ProseMirror has had step maps for a decade and every serious editor has some version of the idea. The difference is the default: there, mapping is something you must remember to do, and forgetting it produces a bug that appears only when somebody types during a slow request — which is to say, in production and not in your tests.
Here the async path does not offer you a raw number to hold. ctx.mark() is how you
carry a position across an await, and there is no shorter way to do it wrong.
Never hold a raw position across an await. A number that was correct when you
asked for it is not correct when you come back, and nothing will tell you — the insert
will succeed, in the wrong place.
Next
- Document model · what the integers count.
- Commands · where
ctx.mark()lives. - Recipes · autosave, and other things with a gap in them.