Comments
The document stores comment anchors; the threads themselves (bodies, authors, resolved state) live
in a CommentAdapter you provide. @stesura/comments is the ProseMirror side: plugins, commands
and stores. @stesura/comments-react is the UI, see Comments (React).
Setup
Add the plugins to the mount hook, the UI extension to the editor, and wrap it in a
CommentProvider:
import { commentsPlugins } from "@stesura/comments";
import { CommentProvider, commentsUiExtension } from "@stesura/comments-react";
const uiExtensions = [commentsUiExtension]; // module scope: must be stable
const Editor = ({ adapter }) => {
const { editorState, dispatch, pluginFactory, schema } = useLocalEditor({
extraPlugins: () => [...commentsPlugins()],
});
return (
<CommentProvider value={adapter}>
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
uiExtensions={uiExtensions}
/>
</CommentProvider>
);
};Without an adapter the comments UI renders nothing. For collaboration,
@stesura/collab-adapter-react provides one, see Collaboration.
Anchors
- Range comments:
comment_open/comment_closeinline atoms around the text, both carrying thethreadIdand ananchorId. A thread can have several anchors (e.g. after copy-paste within the document), each pair with its ownanchorId. - Node comments: a
{ threadId }entry in a node’snodeCommentsattribute, anchored by the node’sid. Opt-in per node type withglobalAttributes: { nodes: { nodeComment: { table: true } } }, see Global attributes.
Comments can’t be added in headers, footers, footnotes or endnotes.
Lifecycle
insertPendingComment / insertPendingNodeComment composer opens on the target
adapter.createThread({ body, metadata: { editorId } })
insertComment(thread.id) / setNodeComment(thread.id) anchor written, thread selectedIf the pending comment closes while createThread is in flight (the selection moved, Escape), the
composer deletes the new thread instead of anchoring it. closePendingComment cancels.
commentBoundaryPlugin then keeps anchors in step with the content:
- Deleting part of a range moves its boundary to the edge of the deletion; deleting all of its content removes the anchor (Word semantics).
- A thread whose last anchor is removed is reported as orphaned, and comments-react soft-deletes it. When its anchors come back (undo, paste, drag-move) it is reported as restored and un-deleted.
- On load, broken boundary pairs are removed and duplicated
anchorIds re-anchored (healDanglingCommentAnchors). - Copy-paste keeps an anchor (with a fresh
anchorId) only when it was copied from the same editor with both boundaries. Anchors from another editor or from external HTML, half pairs, and anything pasted into a header, footer or note are stripped. Node comment entries are kept.
When the editor mounts and threads have loaded, comments-react also soft-deletes threads older than a minute that have no anchor, and 30 seconds later removes anchors whose thread the adapter doesn’t know.
Plugins
commentsPlugins() returns all five, in the order they must be registered:
| Plugin | Purpose |
|---|---|
pendingCommentPlugin() | The comment being composed: isPending plus either pendingSelection (range) or pendingNodeId (node). Highlights the target; any selection change closes it. |
commentBoundaryPlugin() | Keeps anchors valid through edits, copy and paste; reports orphaned and restored threads; heals the document on load. |
commentsDecorationsPlugin() | Highlights the anchors of every visible thread. Rebuilds when the visible-threads store changes. |
commentSelectionPlugin() | Tracks the selected thread and anchor, selecting the comment under the caret, and highlights the selected thread. |
commentsStoreSyncPlugin() | Mirrors the selection, pending comment and thread positions into the stores the UI reads. Must be last. |
Commands
Exported from @stesura/comments:
| Command | Signature | Purpose |
|---|---|---|
insertPendingComment | Command | Opens the composer on the selection, or on the word at the caret. False in code blocks, cell selections, block-node selections and headers/footers/notes. |
insertPendingNodeComment | Command | Opens the composer on the selected node, or the nearest ancestor that supports node comments. False for range selections and nodes without an id. |
closePendingComment | Command | Closes the composer. False when nothing is pending. |
insertComment | (threadId: string | null) => Command | Wraps the pending range in a boundary pair for the thread and selects it. False without a non-empty pending range. |
setNodeComment | (threadId: string | null) => Command | Node counterpart of insertComment: adds the thread to the pending node’s nodeComments. |
deleteCommentsWithThreadId | (threadId: string | null) => Command | Removes the thread’s anchors (boundaries and node comments), skipping headers/footers/notes. False for null or when nothing matches. |
removeCommentAnchors | (threadIds: ReadonlySet<string>) => Command | Removes the anchors of several threads, across the whole document. |
healDanglingCommentAnchors | Command | Removes broken boundary pairs and re-anchors duplicated anchorIds. Runs once on load. |
setSelectedThreadId | (threadId: string | null) => Command | Selects a thread, or deselects with null. |
insertMention | (match: MentionMatch, userId: string) => Command | In the comment composer: replaces an @query match with a mention. |
The anchor-removing commands don’t call the adapter, but with comments-react mounted a thread left without anchors is then soft-deleted as orphaned.
Reading pending state
For a custom composer:
pendingCommentPluginKey.getState(state)gives{ isPending, pendingSelection, pendingNodeId }. A range target setspendingSelection, a node targetpendingNodeId; use it to choose betweeninsertCommentandsetNodeComment.findNodeCommentTarget(doc, nodeId)resolvespendingNodeIdto{ node, pos }, ornullif the node is gone.
Stores
Per-editor nanostores, exported from @stesura/comments (getVisibleThreadsAtom /
setVisibleThreadsAtom from @stesura/core/stores, since the editor reads it too). Each getter creates the atom on first use
and returns the same instance afterwards, so they are safe useStore targets.
| Store | Written by | Value |
|---|---|---|
getThreadFiltersAtom(editorId) | the “Show comments” menu | ThreadFilter (resolved?, deleted?, authorId?, createdAfter?, createdBefore?). Default: resolved and deleted threads hidden. |
getVisibleThreadsAtom(editorId) | comments-react, via setVisibleThreadsAtom | IDs of the threads to highlight. undefined until threads load (nothing is highlighted); an empty set when none are visible or comments are hidden. |
getCommentSelectionAtom(editorId) | commentsStoreSyncPlugin | { selectedThreadId, selectedAnchorId } |
getPendingCommentAtom(editorId) | commentsStoreSyncPlugin | The pending plugin’s state. |
getThreadPositionsAtom(editorId) | commentsStoreSyncPlugin | ThreadPositionMap: each decorated thread’s { from, to } span. The selected thread’s from is its selected anchor. |
getOrphanedThreadsAtom(editorId) | commentBoundaryPlugin | Threads whose last anchor a local edit removed. |
getRestoredThreadsAtom(editorId) | commentBoundaryPlugin | Threads whose anchors came back (undo, paste, drag-move). |
Without comments-react you must write the visible set yourself (setVisibleThreadsAtom), or
nothing is highlighted.
Orphan and restore reports are a queue: takeOrphanedThreads(editorId, ids) /
takeRestoredThreads(editorId, ids) remove only the ids you handled, and the rest are re-emitted,
e.g. while the adapter hasn’t synced a thread yet. clearOrphanedThreads / clearRestoredThreads
drop everything. clearCommentStores(editorId) resets every store on unmount.
Helpers
Exported from @stesura/comments:
| Helper | Purpose |
|---|---|
getThreadDecoSpecsAtPos(state, pos) | { threadId, anchorId } of the comment at a position (the innermost one when several overlap), or null. |
getDocThreadIds(doc) | Every thread ID anchored in the document, visible or not. |
filterThreads(threads, filter) | Applies a ThreadFilter. |
getMentionMatch(state) | The @query being typed before the caret in the comment composer, or null. |
Thread serialization helpers live in @stesura/core/helpers, so adapters can use them without
depending on @stesura/comments:
| Helper | Purpose |
|---|---|
deserializeThread(thread) | Converts a persisted thread (ISO dates) into a Thread. Never throws on malformed input. |
normalizeSerializedThread(thread) | Drops malformed comments and reactions, keeping the persisted shape. See the adapter contract. |
addReactionToComment(comment, emoji, userId) | Returns a copy with the reaction added. Expects normalized input. |
removeReactionFromComment(comment, emoji, userId) | Returns a copy with the reaction removed. Expects normalized input. |
AI tools
@stesura/comments/ai-tools lets the AI chat
read and write comments. Declare commentToolDeclarations in the chat route, and bind the
executors in the editor to the adapter your comments UI uses:
// the route's tool set
import { commentToolDeclarations } from "@stesura/comments/ai-tools";
const editorTools = [...builtInAiTools, ...commentToolDeclarations];
// the editor page, for createAiUIExtension({ useChatTools })
import { bindCommentTools } from "@stesura/comments/ai-tools";
const useChatTools = () => {
const adapter = useCommentCtx();
return useMemo(() => (adapter ? [...editorTools, ...bindCommentTools(adapter)] : editorTools), [adapter]);
};| Export | Purpose |
|---|---|
commentToolDeclarations | The four declarations below, in bindCommentTools order. |
listCommentsDeclaration | list_comments: the threads, their anchors and comments. Open threads by default. |
addCommentDeclaration | add_comment: comments on a block, or a quote inside it. |
replyCommentDeclaration | reply_comment: replies in a thread. |
resolveCommentDeclaration | resolve_comment: resolves a thread. |
bindCommentTools(adapter) | The same tools with executors bound to adapter. |
AiCommentToolsAdapter | What the executors need from the adapter: a structural subset of CommentAdapter (createThread, addComment, resolveThread, getThreadsForExport, updateThreadMetadata, …). |
What the agent adds, replies and resolves is a pending suggestion (metadata.aiSuggestions),
authored by the agent, until the user accepts or rejects it.
Next steps
- Comments (React): the
CommentAdaptercontract, hooks and UI components. - Collaboration: multi-user setup.