# Text anchors (/docs/client/collaborative-documents/anchors)

---
title: Text anchors
description: A comment is pinned to a sentence, not to character 412. An anchor is that pin. It moves as other people type above it, and it says so when the sentence is gone.
kind: guide
package: questpie
---

Store a raw offset and the next insertion above it makes you wrong. The field
can track the position for you instead. This works on `text` fields only.

## Create one, resolve it later

```ts
const anchor = article.fields.content.anchors.create({
	kind: "range",
	start: 12,
	end: 20,
});

const current = article.fields.content.anchors.resolve(anchor);
if (current.status === "resolved" && current.kind === "range") {
	highlight(current.start, current.end);
}
```

`create` takes a point or a range and hands back a token. `resolve` takes the
token and hands back where it points now.

| You create                      | `resolve` gives back                                |
| ------------------------------- | --------------------------------------------------- |
| `{ kind: "point", offset }`     | `{ status: "resolved", kind: "point", offset }`     |
| `{ kind: "range", start, end }` | `{ status: "resolved", kind: "range", start, end }` |
| either, once it cannot place it | `{ status: "detached" }`                            |

A range must be ordered and non-empty at creation, so `start` has to be less
than `end`. Later edits may collapse it onto a single offset. That still
resolves.

## Affinity decides which side an insertion lands on

Someone types exactly at your anchor. Affinity says which side of the new text
the anchor ends up on. `"following"` moves the anchor past it. `"preceding"`
leaves the anchor in front of it.

| Anchor      | Option          | Default       |
| ----------- | --------------- | ------------- |
| point       | `affinity`      | `"following"` |
| range start | `startAffinity` | `"following"` |
| range end   | `endAffinity`   | `"preceding"` |

So a range defaults to holding exactly the characters it started with. Text
typed at its start lands outside it. So does text typed at its end. Pass the
options when you want the range to grow instead.

## Create only from acknowledged state

`create` needs a settled field. It throws `CrdtAnchorError` with
`UNACKNOWLEDGED_STATE` in four cases:

- the field is still syncing
- the document is `synchronizing`
- the field has a local edit the server has not acknowledged
- the call is inside `transaction()`

Bad offsets and bad ranges throw `INVALID_INPUT` instead. Wait for the edit to
land, then create the anchor from the resulting state.

`resolve` has no such rule. It works from any readable state, and inside a
transaction it sees that transaction's text.

## The token is opaque

It is a branded string starting with `qpa1_`, at most 2,048 characters. Store
it as a value. Do not parse it, do not build one, and do not treat it as
permission to read anything.

Six things are baked into it. The namespace, the record's incarnation, the field
slot, the field epoch, the engine id and the format version. Resolution compares
all six, and any mismatch returns `{ status: "detached" }`.

A malformed token detaches too. So does one from another field, and one the
engine can no longer place.

<Callout type="info" title="`replace()` detaches every anchor in the field">
	A server-side `replace` raises the field epoch. Its `reason` is `agent`,
	`import`, `restore` or `resolve`. Recreating the field or the record detaches
	them too.
</Callout>

## Resolving on the server

Routes and hooks get the same two calls on `ctx.crdt`. They are asynchronous
there. The server reloads read authority and the authoritative head on every
call.

```ts title="src/questpie/server/routes/resolve-anchor.post.ts"
import { route } from "questpie/services";
import { z } from "zod";

export default route()
	.post()
	.schema(z.object({ articleId: z.string(), anchor: z.string() }))
	.handler(({ crdt, input }) =>
		crdt.collections.articles
			.document({ id: input.articleId })
			.fields.content.anchors.resolve(input.anchor),
	);
```

Holding a token grants nothing. The caller still needs read access to the field,
checked on that request.

## The working result

A comment thread survives a morning of other people's typing. It follows the
sentence it was attached to. It reports honestly when that sentence was
replaced. The same token resolves the same way in the browser and on the server.
