From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
UNPUBLISHED
  • Overview
  • Why This Fork
  • Examples
Walkthroughs
  • Installing Plite
  • Adding Event Handlers
  • Defining Custom Elements
  • Applying Custom Formatting
  • Executing Commands
  • Saving to a Database
  • Canonical Change Substrate
  • Improving Performance
Concepts
  • Interfaces
  • Nodes
  • Locations
  • Transforms
  • Document Changes
  • Commands
  • Editor
  • Extensions
  • Rendering
  • Serializing
  • Normalizing
  • Using TypeScript
  • Roots
  • Document State
  • Editing Behavior
  • Selection And DOM
  • Clipboard And Paste
  • Projection And Overlays
  • Schema
API
  • Anchor API
  • Location API
  • Path API
  • PointEntry API
  • Point API
  • Range API
  • Selection API
  • Location Types APIs
  • Span API
  • Editor
  • Element API
  • NodeEntry API
  • Node API
  • Node Types APIs
  • Text API
  • Debug Value Scrubbing
  • Transforms API
Libraries
  • Plite DOM
  • History Editor API
  • History Extension Setup
  • History
  • Plite History
  • Plite Hyperscript
  • Plite Layout
  • Annotations
  • DOM Coverage Boundaries
  • Editable Component
  • Plite React Event Handling
  • Virtualized Rendering
  • Plite React Hooks
  • React Editor Setup
  • React Editor
  • Plite React
  • Plite Component
  • Plite Yjs
  • Plite
General
  • Migration
  • Contributing
  • Docs Proof Map
  • FAQ
  • Resources

Annotations

PreviousNext

Attach durable range identities to editor text and project them into React UI.

Annotations attach durable ranges to editor text and publish them to React rendering, sidebars, and widgets. Use Projection And Overlays for the broader choice between decorations, annotation stores, widgets, and projected text rendering.

When To Use Annotations

Use annotations when a range has identity beyond a single render pass: review comments, issue markers, tracked external diagnostics, resolved threads, or anchored suggestions.

Use decorations for transient paint such as search matches. Use widgets for UI that hangs off a node, selection, or annotation.

Annotation Shape

type









Plite LayoutDOM Coverage Boundaries

On This Page

When To Use AnnotationsAnnotation ShapeLocal AnchorsExternal Comment StoresComment ChannelsWidgetsExternal Anchor AdapterDocument-Embedded IdsPerformance Rules
Build your editor
Production-ready AI template and reusable components.
Get all-access
PliteAnnotationAnchor
=
{
resolve(): Range | null;
release?(): Range | null;
};
type PliteAnnotation<TData, TProjection> = {
anchor: PliteAnnotationAnchor;
data?: TData;
id: string;
projection?: TProjection;
};
type PliteAnnotationAnchor = {
  resolve(): Range | null;
  release?(): Range | null;
};
 
type PliteAnnotation<TData, TProjection> = {
  anchor: PliteAnnotationAnchor;
  data?: TData;
  id: string;
  projection?: TProjection;
};

anchor resolves the current range. A local Anchor<Range> satisfies this contract. Adapters can use the same contract for service-owned anchors, remote positions, or document-embedded ids.

data is application metadata. It is returned by usePliteAnnotation and usePliteAnnotations.

projection is the small render-facing payload copied into text projection slices. Put only fields that affect inline paint here.

const annotations = comments.map((comment) => ({
  anchor: comment.anchor,
  data: comment,
  id: comment.id,
  projection: {
    status: comment.status,
    tone: comment.tone,
  },
}));
const annotations = comments.map((comment) => ({
  anchor: comment.anchor,
  data: comment,
  id: comment.id,
  projection: {
    status: comment.status,
    tone: comment.tone,
  },
}));

Local Anchors

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: "drop" }
);
 
const annotationStore = usePliteAnnotationStore(editor, [
  {
    anchor,
    data: { label: "Comment 1" },
    id: "comment-1",
    projection: { tone: "review" },
  },
]);
const anchor = editor.anchor(
  {
    anchor: { path: [0, 0], offset: 3 },
    focus: { path: [0, 0], offset: 18 },
  },
  { association: "inward", deletion: "drop" }
);
 
const annotationStore = usePliteAnnotationStore(editor, [
  {
    anchor,
    data: { label: "Comment 1" },
    id: "comment-1",
    projection: { tone: "review" },
  },
]);

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,
  projection: { tone: comment.tone },
}));
 
const annotationStore = usePliteAnnotationStore(editor, annotations);
const annotations = comments.map((comment) => ({
  anchor: comment.anchor,
  data: comment,
  id: comment.id,
  projection: { tone: comment.tone },
}));
 
const annotationStore = usePliteAnnotationStore(editor, annotations);

Pass the store to Plite when editor-local annotation UI lives under that provider. usePliteAnnotations() and usePliteAnnotation(id) read that store by default.

<Plite annotationStore={annotationStore} editor={editor}>
  <Editable renderSegment={renderCommentSegment} />
  <CommentsSidebar />
</Plite>;
 
function CommentsSidebar() {
  const snapshot = usePliteAnnotations();
 
  return snapshot.allIds.map((id) => {
    const comment = snapshot.byId.get(id);
 
    return <CommentThread key={id} comment={comment} />;
  });
}
<Plite annotationStore={annotationStore} editor={editor}>
  <Editable renderSegment={renderCommentSegment} />
  <CommentsSidebar />
</Plite>;
 
function CommentsSidebar() {
  const snapshot = usePliteAnnotations();
 
  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();

External Comment Stores

Comment bodies, permissions, resolved state, and audit events belong to the app or sync service. The Plite 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,
  projection: {
    status: comment.status,
    tone: comment.tone,
  },
}));
const comments = useCommentChannel();
 
const annotations = comments.map((comment) => ({
  anchor: comment.anchor,
  data: {
    body: comment.body,
    label: comment.label,
    status: comment.status,
  },
  id: comment.id,
  projection: {
    status: comment.status,
    tone: comment.tone,
  },
}));

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:

  • omit ids for a full refresh
  • pass an empty array for a no-op
  • pass ids to re-resolve only those annotations

Comment Channels

Use 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:

  • edit mode on the left, editable document channel
  • comment mode on the right, read-only document with writable comments
  • shared external comment state
  • same resolved anchors rendered in both panes

The comment-mode controls do not call editor.update or mutate the document.

Widgets

Widgets are app-owned UI descriptors anchored to a node, selection, or annotation. Use them for floating buttons, comment popovers, review toolbars, or side-panel rows that need resolved visibility.

type PliteWidgetAnchor =
  | { annotationId: string; type: "annotation" }
  | { nodeKey: NodeKey; type: "node" }
  | { type: "selection" };
 
type PliteWidget<TData> = {
  anchor: PliteWidgetAnchor;
  data?: TData;
  id: string;
};
type PliteWidgetAnchor =
  | { annotationId: string; type: "annotation" }
  | { nodeKey: NodeKey; type: "node" }
  | { type: "selection" };
 
type PliteWidget<TData> = {
  anchor: PliteWidgetAnchor;
  data?: TData;
  id: string;
};

Create widget stores from the current widget array.

const widgets = comments.map((comment) => ({
  anchor: { annotationId: comment.id, type: "annotation" },
  data: { label: comment.label },
  id: `comment-widget:${comment.id}`,
}));
 
const widgetStore = usePliteWidgetStore(editor, widgets, {
  annotationStore,
});
const widgets = comments.map((comment) => ({
  anchor: { annotationId: comment.id, type: "annotation" },
  data: { label: comment.label },
  id: `comment-widget:${comment.id}`,
}));
 
const widgetStore = usePliteWidgetStore(editor, widgets, {
  annotationStore,
});

Use usePliteWidgets(store) for panels that render every widget. Use usePliteWidget(store, id) when one component watches one widget.

External Anchor Adapter

An external adapter can keep the document and comments in separate stores.

type ExternalAnnotationAnchor = {
  resolve(): Range | null;
  unref(): Range | null;
};
 
const anchor = externalAnnotationAdapter.anchorFromPliteRange(editor, range);
 
commentChannel.set(threadId, {
  anchor,
  body,
  status: "open",
});
type ExternalAnnotationAnchor = {
  resolve(): Range | null;
  unref(): Range | null;
};
 
const anchor = externalAnnotationAdapter.anchorFromPliteRange(editor, range);
 
commentChannel.set(threadId, {
  anchor,
  body,
  status: "open",
});

The adapter owns mapping, drift recovery, deletion policy, and permissions. Plite React owns projection once the adapter provides an anchor.

Document-Embedded Ids

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.

Performance Rules

Keep annotation rows stable when their range and render payload do not change.

Keep body text, author data, permissions, and long thread metadata in data. Keep inline paint fields in projection.

Use refresh({ ids }) for external comment updates when the changed ids are known. Fall back to refresh() when the external source cannot provide ids.

Runtime subscribers wake only when their projected ranges or projection data change. A comment body edit should wake annotation/sidebar subscribers without repainting inline text when projection is unchanged.