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 happenedDocument7–12 becomesResult
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 collapsesfrom 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

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

Edit this page on GitHub