QUESTPIE
ClientCollaborative documents

Text anchors

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.

View markdown

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

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 createresolve 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.

AnchorOptionDefault
pointaffinity"following"
range startstartAffinity"following"
range endendAffinity"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.

`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.

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.

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.

On this page