Annotations attach durable ranges to editor text and publish them to sidebars, widgets, and other React UI. Use Decorations, annotations, and widgets for the broader choice between decorations, annotation stores, widgets, and projected text rendering.
Use annotations when a range has identity beyond a single render pass: review comments, issue markers, tracked external diagnostics, resolved threads, or other application-owned ranges.
Use decorations for transient paint such as search matches. Use widgets for UI that hangs off a node, selection, or annotation.
import type { Anchor, Range } from "platejs";
type AnnotationAnchor = Pick<Anchor<Range>, "release" | "resolve">;
type Annotation<TData> = {
anchor: AnnotationAnchor;
data?: TData;
id: string;
};anchor resolves the current range in the store's exact editor view. A local Anchor<Range> satisfies this contract.
Adapters can use the same contract for service-owned anchors, remote positions,
or document-embedded ids.
Each store passes its editor to anchor.resolve(editor). Stores for different
views of the same model can share one retained anchor while reading different
ranges. A projection change in one view does not change another store's view.
Getters refresh ranges lazily after document or projection changes and otherwise
reuse the index. Passive reads do not subscribe. Observation starts with the
first subscriber and stops when the last unsubscribes. destroy() permanently
stops observation and clears subscribers; reads cannot reactivate the store.
data is application metadata. It is returned by useAnnotation and
useAnnotations.
Inline paint belongs to the owning plugin's decorate field. The feature that owns the
visual states converts resolved annotations into keyed decoration attributes.
Use editor.anchor when the anchor belongs to the local editor runtime.
const anchor = editor.anchor(
{
anchor: { path: [0, 0], offset: 3 },
focus: { path: [0, 0], offset: 18 },
},
{ association: "inward", deletion: "nearest" }
);
const annotationStore = useAnnotationStore(editor, [
{
anchor,
data: { label: "Comment 1" },
id: "comment-1",
},
]);const anchor = editor.anchor(
{
anchor: { path: [0, 0], offset: 3 },
focus: { path: [0, 0], offset: 18 },
},
{ association: "inward", deletion: "nearest" }
);
const annotationStore = useAnnotationStore(editor, [
{
anchor,
data: { label: "Comment 1" },
id: "comment-1",
},
]);Use nearest for durable annotations. Complete deletion collapses the range to
a valid position, while saved undo and redo restore its exact endpoints.
When annotations come from React state, pass the current array. A new array identity refreshes the store automatically.
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
}));
const annotationStore = useAnnotationStore(editor, annotations);const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
}));
const annotationStore = useAnnotationStore(editor, annotations);Wrap annotation readers in AnnotationProvider when they should share a
default store. The annotation provider is independent from the editor provider,
so another subtree can use a different store.
<EditorRoot editor={editor}>
<AnnotationProvider store={annotationStore}>
<EditorContent />
<CommentsSidebar />
</AnnotationProvider>
</EditorRoot>;
function CommentsSidebar() {
const snapshot = useAnnotations();
return snapshot.allIds.map((id) => {
const comment = snapshot.byId.get(id);
return <CommentThread key={id} comment={comment} />;
});
}<EditorRoot editor={editor}>
<AnnotationProvider store={annotationStore}>
<EditorContent />
<CommentsSidebar />
</AnnotationProvider>
</EditorRoot>;
function CommentsSidebar() {
const snapshot = useAnnotations();
return snapshot.allIds.map((id) => {
const comment = snapshot.byId.get(id);
return <CommentThread key={id} comment={comment} />;
});
Release anchors when the app removes the annotation.
anchor.release();anchor.release();Comment bodies, permissions, resolved state, and audit events belong to the app or sync service. The Plate document value owns document content. Use Document Meta for document metadata and settings that should persist with the document.
const comments = useCommentChannel();
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: {
body: comment.body,
label: comment.label,
status: comment.status,
},
id: comment.id,
}));const comments = useCommentChannel();
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: {
body: comment.body,
label: comment.label,
status: comment.status,
},
id: comment.id,
}));External changes to annotation membership or data require refresh; passive
getter freshness tracks document and projection changes. When an external store
knows which comments changed, refresh those ids.
annotationStore.refresh({
ids: [threadId],
reason: "annotation",
});annotationStore.refresh({
ids: [threadId],
reason: "annotation",
});Refresh semantics:
ids for a full refreshUse separate channels for the document and the comments.
// Writer lane: document channel.
writerEditor.update((tx) => {
tx.text.insert("hello", { at });
});
// Reviewer lane: annotation channel.
commentsMap.set(threadId, {
anchor,
body,
status: "open",
});
annotationStore.refresh({ ids: [threadId], reason: "annotation" });// Writer lane: document channel.
writerEditor.update((tx) => {
tx.text.insert("hello", { at });
});
// Reviewer lane: annotation channel.
commentsMap.set(threadId, {
anchor,
body,
status: "open",
});
annotationStore.refresh({ ids: [threadId], reason: "annotation" });A read-only reviewer can select text, create a comment anchor, and update a thread without document-write permission. The adapter resolves the anchor against the current document snapshot for rendering.
The comment-mode example renders this as two panes:
The comment-mode controls do not call editor.update or mutate the
document.
An external adapter can keep the document and comments in separate stores.
import type { Editor, Range } from "platejs";
type ExternalAnnotationAnchor = {
release(): Range | null;
resolve(view?: Editor): Range | null;
};
const anchor = externalAnnotationAdapter.anchorFromRange(editor, range);
commentChannel.set(threadId, {
anchor,
body,
status: "open",
});import type { Editor, Range } from "platejs";
type ExternalAnnotationAnchor = {
release(): Range | null;
resolve(view?: Editor): Range | null;
};
const anchor = externalAnnotationAdapter.anchorFromRange(editor, range);
commentChannel.set(threadId, {
anchor,
body,
status: "open",
});The adapter owns mapping, drift recovery, deletion policy, and permissions. Resolve into the supplied view's coordinates. Omitting the view uses the capture view; an explicit view must belong to the same model and document root. See Anchor API for release and aborted-capture behavior.
Document-embedded ids are useful when the product wants comments to copy, paste, serialize, or travel with document content.
Use this as an adapter strategy, not as the default storage model for comment bodies or permissions. The document may store a lightweight id; the comment thread still belongs to the app or sync service.
Keep annotation rows stable when their range and app data do not change.
Use refresh({ ids }) for external comment updates when the changed ids are
known. Fall back to refresh() when the external source cannot provide ids.
Targeted subscribers wake only when their resolved range or data changes. Decoration subscribers are independent and wake only when merged text attributes change.