Comments
On this page
@better-rich-text/plugin-comments adds threaded comments anchored to text: a margin sidebar with cards beside their text, inline thread popovers, replies, @mentions, reactions, resolve and reopen, unread markers, filters, permissions (including comment-only review), optional storage adapters and export.
Threads are document annotations, so they work standalone (saved in the JSON envelope), sync through the collaboration log (see ADR 0004) and never appear in getHTML().
Quick start
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { commentsPlugin, createCommentsFeature } from '@better-rich-text/plugin-comments';
import { createUsersPlugin } from '@better-rich-text/users';
const editor = createRichTextEditor('#editor', {
plugins: [
createUsersPlugin({
currentUserId: session.userId,
fetchUsers: (ids) => api.users(ids), // [{ id, name, avatar?, description?, color? }]
searchUsers: (query) => api.searchUsers(query), // powers @mentions
}),
commentsPlugin,
],
features: [...standardPreset.features, createCommentsFeature({ sidebar: 'margin' })],
toolbar: `${standardPreset.toolbar} | comment comments`,
});The users plugin is optional. Without it, comments use setCommentsUser, the editor's actorId and the authorName config, exactly as before.
The demo is apps/playground/comments.html.
Features
| Feature | Export |
|---|---|
| Toolbar button, Mod-Alt-M, inline threads, keyboard navigation, announcements | commentsFeature |
All of the above plus the margin sidebar and the comments toggle with a count badge |
commentsSidebarFeature |
| Either, configured | createCommentsFeature(options) |
| Review panel as a list, mounted anywhere | CommentsPanel |
| Margin column that other card types can join | MarginSidebar, SidebarItem |
Feature options
| Option | Default | Meaning |
|---|---|---|
sidebar |
false |
'margin' shows cards beside the text. |
narrow |
'drawer' |
Below the breakpoint the sidebar becomes a toggleable drawer, or the list panel under the text. |
breakpoint |
760 |
Editor width in px where the sidebar collapses. |
inline |
'auto' |
Inline popovers. auto opens them unless margin cards are showing. |
hidden |
false |
Start with the sidebar hidden. |
filter |
'open' |
open, resolved, mine, mentions or all. |
sort |
'position' |
position or recent (latest activity). Applies to the list and drawer forms. |
mentionProvider |
directory search | (query, signal) => Promise<Person[]>. |
attachMentions |
built-in | Swaps the reply-box autocomplete. |
reactions |
👍 ❤️ 🎉 😄 👀 ✅ | Emoji offered in the picker. |
formatTime |
relative | Formats message times. |
ui.feature<CommentsController>('comments') returns the controller: sidebar, threads, inline, layout(), isOpen(), toggle(open?) and openThread(id, focus?).
Margin sidebar
Each thread is a card level with the first line of its text. Cards stack instead of overlapping. The active card moves to sit exactly beside its text; cards before it move up, and any that would cross the top of the column are hidden until the active card changes. Positions are recomputed in one animation frame after edits, remote changes, scrolling, resizing (a ResizeObserver watches the content and every card), images loading and web fonts loading.
Clicking a card selects its text and scrolls to it. Clicking highlighted text activates its card.
Hovering a card or highlighted text highlights the thread's whole range.
The sidebar is an
asidelabelled "Comments". Cards are focusable; Enter or Space activates one; Escape returns to the text.Inactive cards show the first message (three lines), reactions and a reply count; the active card shows the whole thread.
Other card types
MarginSidebar is not specific to comments. Join it with any object that implements SidebarItem:
export interface SidebarItem {
readonly id: string; // unique in the sidebar; prefix by kind, e.g. 'suggestion:42'
readonly element: HTMLElement; // the card; the sidebar positions it and makes it focusable
range(): { from: TextPoint; to: TextPoint } | null; // read at every layout; null stacks it last
readonly priority?: number; // lower first when two cards share a line (default 0)
activate?(): void; // pressed, or Enter/Space
}const { sidebar } = ui.feature<CommentsController>('comments')!;
const handle = sidebar!.add({ id: `suggestion:${s.id}`, element: card, range: () => s.range, priority: -1 });
sidebar!.setActive(`suggestion:${s.id}`);
handle.dispose();Comment cards use the id prefix comment:. stackLayout(items, { gap, active, minTop, overflowTop }) is the pure layout function, exported for tests and custom columns.
Inline threads
Clicking commented text opens a popover with the whole thread, a reply box and the thread's actions. Moving the caret into commented text opens it without taking focus, so typing continues. The popover is a non-modal dialog labelled with the commented text.
Keyboard
| Keys | Action |
|---|---|
| Mod-Alt-M | Comment on the selection. On commented text, move focus into its thread. |
| Mod-Alt-] | Next open thread (comment.next), focus in its reply box. |
| Mod-Alt-[ | Previous open thread (comment.previous). |
| Mod-Enter | Send a reply, comment or edit. |
| Escape | Close suggestions, a confirmation or an edit; then return focus to the text. |
Mod is Cmd on Apple platforms and Ctrl elsewhere.
Commands
All commands validate input with v.* schemas and carry labels, descriptions and aliases, so they appear in editor.toolDefinitions() for AI tool use. They are allowed on read-only documents; the permission filters decide.
| Command | Input |
|---|---|
comment.create |
{ body } (on the selection) |
comment.reply |
{ threadId, body } |
comment.resolve, comment.reopen |
{ threadId } |
comment.editMessage |
{ threadId, messageId, body } |
comment.deleteMessage |
{ threadId, messageId } |
comment.delete |
{ threadId } |
comment.react |
{ threadId, messageId, emoji } (toggles) |
comment.reattach |
{ threadId } (on the selection) |
comment.next, comment.previous |
none |
Undo behaves as before: in a standalone editor comment commands are part of local history; with collaboration, undo skips comment-only transactions. Navigation is never recorded.
Mentions
Mentions are stored inline as @[Name](id), so bodies stay plain strings everywhere. While typing, people appear as @Name; the token is written when the reply is sent.
The reply box suggests people from, in order: the mentionProvider option, then the user directory's search when the host configured searchUsers. With neither, mentions are off. The built-in autocomplete is an ARIA combobox with a listbox (arrow keys, Enter or Tab to pick, Escape to close). Replace it with attachMentions: (textarea, provider) => MentionInput.
Helpers: mentionToken, parseMentions, splitMentions, mentionsToText, textToMentions, staticMentionProvider, directoryMentionProvider.
Notifications
editor.hooks.addAction('comments.mentioned', ({ threadId, messageId, people, author, text, excerpt }) => {
api.notify(people.map((p) => p.id), { threadId, messageId, from: author.name, text, excerpt });
});It runs once per message that newly mentions someone (and once for people added by an edit), never for self-mentions, and only where the message was written: the author's editor and the collaboration authority, not other replicas.
People
Comments store authorId plus a name snapshot. Names, avatars, descriptions and colours are shown from the user directory (directory.get(id)), which returns a placeholder at once; cards repaint when the directory reports the real record. Without a directory, or while a record is loading, the stored name is shown.
Who is acting, in order: the collaboration actor service, a user set with setCommentsUser(editor, user), the directory's currentUserId, then the editor's actorId.
Avatars are initials on a colour derived from the id unless the directory has an avatar URL or color. The comments.avatarUrl filter has the final say.
Permissions
| Filter | Default |
|---|---|
comments.canCreate |
Allowed |
comments.canReply |
Allowed |
comments.canResolve |
Allowed (also reopen) |
comments.canEdit |
Message author only |
comments.canDelete |
Message author; for a whole thread, its starter |
comments.canReact |
Allowed |
Every filter receives (allowed, { action, user, role, readOnly, thread?, message? }). Commands enforce the same answer the interface uses, so hidden buttons and refused commands always agree.
editor.hooks.addFilter('comments.canEdit', (allowed, { user }) => allowed || moderators.has(user.id));Roles
| Role | Comments | Text |
|---|---|---|
editor (default) |
While the document is editable | Editable |
commenter |
Always, even when read-only | Up to the host |
viewer |
Read only | Up to the host |
Comment-only review: config: { comments: { role: 'commenter' } } with readOnly: true, or at runtime setCommentsRole(editor, 'commenter'); editor.setReadOnly(true). The read-only content stays focusable so text can be selected and commented from the keyboard.
With collaboration, the authority still checks each actor's comment/edit permission.
Unread
A message is unread for a person when someone else wrote or edited it after they last saw the thread. Seeing a thread (activating its card or opening its popover) records the time.
editor.hooks.addFilter('comments.lastSeen', (local, { threadId, userId }) => seen.get(`${userId}:${threadId}`) ?? local);
editor.hooks.addAction('comments.seen', ({ threadId, userId, at }) => api.markSeen(threadId, userId, at));Storage
By default threads live in the document envelope. To keep them in your own backend, implement CommentsStore and connect it:
interface CommentsStore {
load(): Promise<readonly CommentThread[]>;
create(thread: CommentThread): Promise<void>;
reply(threadId: string, message: CommentMessage): Promise<void>;
update(thread: CommentThread): Promise<void>;
subscribe?(listener: (change: { type: 'upsert'; thread: CommentThread } | { type: 'remove'; threadId: string }) => void): () => void;
}
const connection = connectCommentsStore(editor, store, { anchorDelayMs: 1000 });
await connection.ready;
// connection.flush() waits for pending writes; connection.dispose() disconnects.Writes must be idempotent upserts keyed by id (undo and redo can re-create a thread).
Anchors move with typing; anchor-only changes are written once they settle (
anchorDelayMs).Threads applied from the store are not undoable and are not written back.
Anchors refer to node ids, so save the document as the JSON envelope. Threads whose anchors are not in the loaded document arrive orphaned and can be re-attached.
stripComments(envelope)removes threads from an envelope before you save it, when the store is the record.Failures run the
comments.storeErroraction (or youronError).
MemoryCommentsStore is an in-memory reference implementation; several editors can share one.
Use a store for standalone editors. With real-time collaboration, the authority's log already stores and syncs threads.
REST example
class RestCommentsStore implements CommentsStore {
constructor(private base: string) {} // e.g. /api/documents/report-42/comments
async load() {
const res = await fetch(this.base);
return (await res.json()).threads;
}
async create(thread: CommentThread) {
await fetch(`${this.base}/${thread.id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(thread) });
}
async reply(threadId: string, message: CommentMessage) {
await fetch(`${this.base}/${threadId}/messages/${message.id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(message) });
}
async update(thread: CommentThread) {
await fetch(`${this.base}/${thread.id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(thread) });
}
subscribe(listener: (change: CommentsStoreChange) => void) {
const events = new EventSource(`${this.base}/events`);
events.onmessage = (e) => listener(JSON.parse(e.data));
return () => events.close();
}
}Data shapes
A thread:
{
"id": "cmt-a1b2c3",
"anchor": {
"from": { "nodeId": "n17", "offset": 4, "affinity": "after" },
"to": { "nodeId": "n17", "offset": 23, "affinity": "before" }
},
"excerpt": "staged rollout to ten percent",
"status": "open",
"anchorState": "attached",
"createdBy": "sam",
"createdByName": "Sam Ortiz",
"createdAt": "2026-10-28T09:12:00.000Z",
"messages": [ ]
}anchorisnullwhenanchorStateis"orphaned"(the text was deleted).statusis"open"or"resolved". A resolved thread adds"resolvedBy": "priya","resolvedByName": "Priya Nair"and"resolvedAt"; reopening removes them.A deleted thread is kept as a tombstone with
"deleted": true.
A message:
{
"id": "msg-d4e5f6",
"authorId": "maya",
"authorName": "Maya Chen",
"body": "Fine by me if @[Lena Okafor](lena) can line up the emails.",
"createdAt": "2026-10-28T10:02:00.000Z",
"editedAt": "2026-10-28T10:05:00.000Z",
"reactions": { "👍": [{ "id": "sam", "name": "Sam Ortiz" }] }
}editedAtandreactionsare optional. A deleted message keeps its id with"body": ""and"deleted": true.Reactions map an emoji (no spaces, up to 16 characters, at most 32 per message) to the people who reacted, in order:
{ "🎉": [{ "id": "lena", "name": "Lena Okafor" }, { "id": "priya", "name": "Priya Nair" }] }Limits: ids up to 128 characters, names 200, bodies 20,000, excerpts 2,000. Times are ISO 8601 UTC strings. Unknown fields are dropped when a thread is read.
Endpoints
| Method and path | Request body | Response |
|---|---|---|
GET /comments |
none | 200 { "threads": Thread[] } |
PUT /comments/{threadId} |
Thread |
204 (create or replace) |
PUT /comments/{threadId}/messages/{messageId} |
Message |
204 (append if new, otherwise no change); 404 when the thread is unknown |
GET /comments/events |
none | text/event-stream; each event's data is { "type": "upsert", "thread": Thread } or { "type": "remove", "threadId": "cmt-a1b2c3" } |
The server should stamp authorship from the session rather than trust authorId, and apply the same permission rules (authors edit and delete their own messages).
Export
getHTML() never includes comments. To include them:
commentsHtml(editor); // <p>We launch… <mark data-comment-thread="cmt-a1b2c3">staged rollout</mark>…</p>
commentsHtml(editor, { includeResolved: true, pretty: true });
exportThreads(editor.state); // { format: 'better-rich-text/comments', version: 1, exportedAt, threads }Overlapping threads share one mark with space-separated ids. exportThreads lists threads in document order with the current anchored text (quote), the original excerpt, authors, resolution, and messages with text (mentions as @Name), parsed mentions and reactions. Options: includeResolved (default true) and includeDeletedMessages (default false).
Word
With the documents plugin, Export Word writes threads as Word comments: each message is a comment with its author, initials and date, replies are threaded under the first message, and resolved threads are marked done. Importing a Word file reads comments back into threads, with replies, resolved state and anchors. Authors are matched to the user directory by name. Pass docx: { comments: false } to leave comments out of exports, or untick Keep comments in the import preview. See Documents for the format details and limits.
Hooks
| Hook | Kind | Purpose |
|---|---|---|
comments.canCreate … comments.canReact |
filter | Permissions |
comments.lastSeen |
filter | When a person last saw a thread |
comments.avatarUrl |
filter | Avatar image for a person |
comments.mentioned |
action | Notify mentioned people |
comments.seen |
action | Persist seen times |
comments.storeError |
action | Store failures |
Styling
Styles live in the brt cascade layer and use only --brt-* tokens, so every skin and the dark theme apply. --brt-sidebar-width (default 296px) sets the margin column width. Highlights use the CSS Custom Highlight API names brt-comment, brt-comment-hover and brt-comment-active.
Main classes: .brt-sidebar, .brt-card, .brt-thread-popover, .brt-thread-view, .brt-message, .brt-reaction, .brt-avatar, .brt-mention, .brt-mention-list, .brt-badge.
Accessibility
The sidebar is a complementary region labelled "Comments"; cards are focusable and labelled with author, text and unread count.
The inline popover is a non-modal dialog labelled by the commented text. Keyboard opens move focus into it; Escape returns focus to the text.
A polite live region announces new comments and replies (with the author's name), and resolve and reopen.
Reactions are toggle buttons (
aria-pressed) labelled with the emoji, count and names.Destructive actions ask for confirmation inline.
Limitations
Cards before the active card can be hidden when there is no room above it; they return when another card is active.
With a
CommentsStore, anchors depend on stable node ids; HTML round trips orphan threads.Mention names are snapshots in the body; rendering shows the directory's current name where one exists.
Inline popovers and margin cards cover one editor surface; the review panel can be mounted anywhere.