Tiptap Studio

Positions

Flat ProseMirror positions in JavaScript, editing by position, and mapping positions through edits.

Tiptap and ProseMirror address every location in a document with one integer, a flat position. TiptapDoc computes the same kind of positions, converts them to and from index paths, applies edits addressed by them, and maps positions through a batch of edits so a selection stays in place.

How positions are counted

  • A text node counts one unit per Unicode scalar value.
  • A leaf node counts 1. In JavaScript the leaves are always image, horizontalRule and hardBreak.
  • Any other node counts 2 plus its content: one token for its start and one for its end.
  • The root has no tokens. Valid positions run from 0 to contentSize().
const doc = TiptapDoc.fromJSON({
  type: "doc",
  content: [
    { type: "paragraph", content: [{ type: "text", text: "Hello world" }] }, // 0 to 13
    { type: "horizontalRule" },                                             // 13 to 14
    { type: "paragraph", content: [{ type: "text", text: "Bye" }] },        // 14 to 19
  ],
});

doc.contentSize(); // 19

Two cases give positions that differ from the ones Tiptap computes in the editor:

  • Custom atom nodes. The leaf set is fixed. An inline atom such as mention counts as an empty container (2) here, while the editor counts it as 1. Every position after it is off by one. The Rust crate accepts a custom leaf set; the JS binding does not.
  • Characters outside the Basic Multilingual Plane, such as most emoji. Here they count as one scalar value. ProseMirror measures text with JavaScript string length, which counts them as two UTF-16 code units.

Translate positions yourself, or avoid position-based APIs, when documents contain either.

For the full model, see Paths and positions.

Read positions

MethodReturnsThrows when
contentSize()number: the largest valid positionnever
resolve(pos: number)ResolvedPospos is greater than contentSize()
posBefore(path: number[])number: the position just before the nodea path index is out of range
posAfter(path: number[])number: the position just after the nodea path index is out of range
posInText(path: number[], offset: number)number: the position at a scalar offset inside the text node at paththe node is not text, or offset is past its end
doc.posBefore([1]); // 13
doc.posAfter([1]); // 14
doc.posInText([2, 0], 1); // 16: after "B"

Use posBefore and posAfter with paths below the root. For the root path [], posAfter returns contentSize() + 2, which is not a valid position.

ResolvedPos

resolve describes where a position falls:

FieldTypeMeaning
posnumberThe position you passed.
depthnumberDepth of the containing node; equals path.length.
pathnumber[]Index path of the node that contains the position.
parentOffsetnumberOffset of the position inside that node's content.
indexnumberIndex of the child at or just after the position.
textOffsetTextPoint or nullSet when the position is strictly inside a text node: { path, offset }, with path pointing at the text node and offset in scalars.
doc.resolve(7);
// { pos: 7, depth: 1, path: [0], parentOffset: 6, index: 0,
//   textOffset: { path: [0, 0], offset: 6 } }

doc.resolve(13);
// { pos: 13, depth: 0, path: [], parentOffset: 13, index: 1, textOffset: null }

Convert to inline positions

The inline editing methods take a block path and a Position ({ child, offset }). These two methods convert between that form and flat positions:

MethodReturns
posToInline(pos: number)BlockInline: { block: number[], inline: Position }
inlineToPos(blockPath: number[], position: Position)number
doc.posToInline(7); // { block: [0], inline: { child: 0, offset: 6 } }
doc.inlineToPos([0], { child: 0, offset: 6 }); // 7

const { block, inline } = doc.posToInline(16);
doc.insertText(block, inline, "!", undefined); // "B!ye"

inlineToPos throws when child is past the block's last child, or when offset does not fit: past the end of a text child, or not 0 for any other child.

A position between top-level blocks resolves to the root, so posToInline returns block: []. inlineToPos([], …) returns one more than the matching position, because it adds a start token the root does not have. Only pass block paths below the root to inlineToPos.

Edit by position

applyPosEdits(edits: PosEdit[]) applies a batch of edits addressed by flat positions and returns the change list it made, a Change[]. Undo it with invert.

EditFieldsEffect
insertpos, contentInserts content at pos.
deletefrom, toDeletes [from, to).
replacefrom, to, contentReplaces [from, to) with content.
addMarkfrom, to, mark: MarkAdds the mark to the text in [from, to).
removeMarkfrom, to, markType: stringRemoves marks of that type from the text in [from, to).
setBlockAttrspos, attrsReplaces all attributes of the node that starts at pos, or of the block containing pos. {} removes them.

content is either { type: "text", text, marks? } or { type: "nodes", nodes }.

const edits = [
  { type: "replace", from: 7, to: 12, content: { type: "text", text: "there" } },
  { type: "insert", pos: 15, content: { type: "text", text: "Good" } },
  { type: "addMark", from: 1, to: 6, mark: { type: "bold" } },
];

const patch = doc.applyPosEdits(edits);
doc.textContent(); // "Hello thereGoodBye"

Rules for a batch:

  • Every position refers to the document before the batch. Edits run from the highest position down, so earlier edits never shift later ones. You do not rebase positions yourself.
  • Edits must not overlap. Spans may touch. An overlap throws pos-edit: overlapping edit at [from,to).
  • The batch is atomic. If any edit fails, the document is left unchanged.
  • A span can cross blocks when both ends sit in sibling blocks under the same parent, such as two top-level paragraphs. The blocks in between are removed and the two ends are joined. Ends at different depths or under different parents throw pos-edit: unsupported cross-block span [from,to).
  • from greater than to throws pos-edit: range: end precedes start.

Insert text at a position inside a textblock. A position between blocks resolves to the parent, so text inserted there lands directly in it.

Map positions through edits

These two module functions carry positions from before a batch to after it. Pass the same PosEdit[] you gave to applyPosEdits.

FunctionReturns
mapPosition(pos: number, edits: PosEdit[], assoc: Assoc)number
mapPositionRange(range: PosRange, edits: PosEdit[], assoc: Assoc)PosRange ({ from, to })

assoc is "left" or "right". It decides where a position inside a replaced span, or exactly at an insertion, lands: "left" keeps it at the start, "right" moves it past the new content. undefined or null means "left". TypeScript requires the argument, so pass it explicitly. mapPositionRange maps both ends with the same assoc. Mark and attribute edits do not move positions.

import { mapPosition, mapPositionRange } from "@tiptap-studio/rusty-parser";

mapPosition(13, edits, "left"); // 13: before the insert, unchanged
mapPosition(15, edits, "left"); // 15: at the insert, stays before "Good"
mapPosition(15, edits, "right"); // 19: moves past "Good"
mapPosition(9, edits, "left"); // 7: inside the replaced "world", to its start
mapPosition(9, edits, "right"); // 12: to its end
mapPositionRange({ from: 13, to: 18 }, edits, "left"); // { from: 13, to: 22 }

A common use is keeping the editor selection in place after an edit you applied outside the editor:

const from = mapPosition(selection.from, edits, "left");
const to = mapPosition(selection.to, edits, "right");

The edits are expected to be disjoint, as applyPosEdits requires. These functions do not check it.

On this page