Skip to Content
Comments

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_close inline atoms around the text, both carrying the threadId and an anchorId. A thread can have several anchors (e.g. after copy-paste within the document), each pair with its own anchorId.
  • Node comments: a { threadId } entry in a node’s nodeComments attribute, anchored by the node’s id. Opt-in per node type with globalAttributes: { 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 selected

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

PluginPurpose
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):

CommandSignaturePurpose
insertPendingCommentCommandOpens 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.
insertPendingNodeCommentCommandOpens the composer on the selected node, or the nearest ancestor that supports node comments. False for range selections and nodes without an id.
closePendingCommentCommandCloses the composer. False when nothing is pending.
insertComment(threadId: string | null) => CommandWraps the pending range in a boundary pair for the thread and selects it. False without a non-empty pending range.
setNodeComment(threadId: string | null) => CommandNode counterpart of insertComment: adds the thread to the pending node’s nodeComments.
deleteCommentsWithThreadId(threadId: string | null) => CommandRemoves the thread’s anchors (boundaries and node comments), skipping headers/footers/notes. False for null or when nothing matches.
removeCommentAnchors(threadIds: ReadonlySet<string>) => CommandRemoves the anchors of several threads, across the whole document.
healDanglingCommentAnchorsCommandRemoves broken boundary pairs and re-anchors duplicated anchorIds. Runs once on load.
setSelectedThreadId(threadId: string | null) => CommandSelects a thread, or deselects with null.
insertMention(match: MentionMatch, userId: string) => CommandIn 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 sets pendingSelection, a node target pendingNodeId; use it to choose between insertComment and setNodeComment.
  • findNodeCommentTarget(doc, nodeId) resolves pendingNodeId to { node, pos }, or null if 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.

StoreWritten byValue
getThreadFiltersAtom(editorId)the “Show comments” menuThreadFilter (resolved?, deleted?, authorId?, createdAfter?, createdBefore?). Default: resolved and deleted threads hidden.
getVisibleThreadsAtom(editorId)comments-react, via setVisibleThreadsAtomIDs 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)commentsStoreSyncPluginThe pending plugin’s state.
getThreadPositionsAtom(editorId)commentsStoreSyncPluginThreadPositionMap: each decorated thread’s { from, to } span. The selected thread’s from is its selected anchor.
getOrphanedThreadsAtom(editorId)commentBoundaryPluginThreads whose last anchor a local edit removed.
getRestoredThreadsAtom(editorId)commentBoundaryPluginThreads 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:

HelperPurpose
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

Last updated on