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 (and @stesura/comments/commands):
| 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. 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. |
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. |
getMentionMatch(state) | The @query being typed before the caret in the comment composer, or null. |
Next steps
- Comments (React): the
CommentAdaptercontract, hooks and UI components. - Collaboration: multi-user setup.