@nextgensoftwares/folio-plugin-comments adds Google-Docs-style comments: select text (or a figure, table, equation, code block) and choose Comment, discuss in threads beside the page, resolve, reopen, react and @mention people. Who sees and does what is entirely up to the host, and the reference server enforces the same rules.
ts
import { composePlugins } from '@nextgensoftwares/folio-editor';import { commentsPlugin, ServerCommentStore } from '@nextgensoftwares/folio-plugin-comments';const comments = commentsPlugin({ user: { id: 'u42', name: 'Mona Adel', color: '#db2777', role: 'editor' }, readers: 'hidden', // 'hidden' (default) | 'view' | 'comment' mentionables: (q) => searchPeople(q), onMention: (e) => notify(e.mentioned, e.thread),});const composed = composePlugins(standardSchema, [comments /* , docxExportPlugin(...), pdfExportPlugin(...) */]);// Threads for this document on the server (default: in memory).comments.setStore(new ServerCommentStore({ baseUrl: 'https://docs.example.com', docId, headers: () => ({ Authorization: token() }) }));
Render composed.ui in <FolioView ui={...}> and pass composed.pageLayers and composed.painters: the plugin contributes
a Comment item (bubble bar and context menu; Mod-Alt-M),
an overlay slot component: the margin rail (cards beside the pages),
a page layer that highlights commented text (stronger for the active thread),
a Comments tab in the sidebar slot (all threads, open/resolved filter, search, jump to text),
optionally a Comments settings section (settings: true).
A thread is anchored by a comment mark carrying its id (text) or by the commentIds attr of a block (figures, tables, code, equations, callouts; blockTypes picks which). The mark is inclusive: false (typing at a comment's end doesn't extend it) and excludes: '' (comments overlap). Neither affects layout: highlights are a page layer, and exporters ignore the mark unless you export comments on purpose.
Why a mark and not decorations plus position mapping? An anchor that is part of the document survives everything the document survives, with no extra code: edits around and inside it, collab rebasing (it is just another step, so other clients and the server see the same anchors), undo, split/join (each half keeps the mark), copy/paste and reloads. Decorations only live in one editor session: they must be re-derived from stored positions after every reload, mapped through remote steps by hand, and drift whenever a client misses a step. The cost is that comment anchors are document content (so the server's schema must include commentSchema(), and a reader, who can't edit the document, can't add a mark; see below).
The threads themselves (CommentThread: author, time, body, replies, resolution, reactions) are kept outside the document in a CommentStore, so permissions and storage are separate from the document's.
Details worth knowing:
Pasting a copy of commented text drops the copy's anchor (the original keeps it); cut and paste moves the comment with its text.
Creating or deleting a comment is not undoable (as in Docs): the anchor edit is outside the undo history. Undoing the deletion of commented text brings the anchor back.
Deleting all of a comment's text orphans the thread: it leaves the rail and shows in the sidebar as "The commented text was removed".
The anchor index is incremental: a transaction maps the known anchors (O(anchors)) and rescans only the top-level blocks it touched, and only when it touches comments. Typing elsewhere costs a map over a short list.
Point comments (no selection) and readers' comments have a floating anchor: the quote and some context, found once and then mapped through edits. The next editor who opens the document turns a reader's floating anchor into a real mark.
Everything is configurable on commentsPlugin({...}) (and live with setOptions / setUser):
Option
Default
readers
'hidden': readers see no highlights, rail or sidebar. 'view': read-only threads. 'comment': they can comment and reply.
isReader(user)
user.role === 'reader'
canView(user)
editors; readers per readers; anonymous (user: null) never
canComment(user)
editors; readers with readers: 'comment'
canResolve(user, thread)
editors; a commenting reader for threads they started
canEdit(comment, user)
the comment's author
canDelete(comment, user, thread)
the comment's author (deleting a thread's root deletes the thread)
canReact(user, thread)
whoever can comment
A rule that throws denies.
Client rules are only UX
The plugin hides what the policy forbids, but a browser can send anything. Enforce on the server: @nextgensoftwares/folio-server-node's comment routes evaluate the same CommentPolicyOptions against the identity your identify hook returns, then your authorize hook, for every request. Never take the author from the request body.
MemoryCommentStore: in memory; every editor in the tab sharing it sees the same threads and typing indicators.
ServerCommentStore: the reference server's REST routes. Writes are optimistic and roll back when refused; other people's changes arrive over a live source (store.setLive(collabClient): the collab socket forwards comment events) or by polling with ETags (pollMs, default 4 s, 4× slower while live).
Your own: implement CommentStore (threads, subscribe, apply, optional typing/typists/status/error). applyOp is the shared reducer, so any backend can apply and validate ops identically.
import { createFolioServer, FileCommentBackend } from '@nextgensoftwares/folio-server-node';createFolioServer({ schema: toProseMirrorSchema(standardSchema.extend(commentSchema())), comments: { identify: (req) => sessionUser(req), // required: who is calling (null = 401) readers: 'view', // the same policy options as the client authorize: (req, docId, action, { user }) => isMember(user, docId), backend: new FileCommentBackend('./data/comments'), onMention: (e) => sendMentionEmails(e), },});
Routes under /docs/:id/comments: GET (list, ETag), POST (create), POST /:thread/replies, PATCH /:thread ({resolved} or {anchor}), PATCH /:thread/:comment (edit), DELETE /:thread[/:comment], PUT|DELETE /:thread/:comment/reactions/:emoji, POST /typing. Every change is also sent to the document's collaborators as a collab event ({ kind: 'comments' | 'comments-typing' }). insecureHeaderIdentity() trusts an X-Folio-User header and exists for demos only.
DOCX: real Word comments: comments.xml, commentRangeStart/End around the anchored text (across paragraphs too), commentReference runs, replies threaded under their root and resolved threads marked done (commentsExtended.xml). Block anchors wrap the block.
PDF: a highlight annotation over each thread's text (a sticky note for blocks and points) with the replies as notes "in reply to" it.
Cards show the author's avatar or initials, name, relative time, body with @mentions, replies, a reply box (when active), resolve/reopen, edit/delete for your own comments and a "…" menu. Clicking highlighted text activates its card; clicking a card (or its quoted text) activates and selects the text. Cards are focusable (Enter opens the reply box, Escape returns to the text), menus work with arrow keys, the composer sends with Ctrl/⌘+Enter. Styles use Folio's UI tokens (light/dark), and highlights go through the document's colour adapter, so dark document themes adapt them.
The rail renders cards only for mounted (visible) pages and stacks them without overlaps around the active card; pages shift left to make room. When there's no room (narrow windows), in reflow and compact layouts, the rail collapses: the active thread opens in a sheet at the bottom of the view and the sidebar lists everything.
Comments plugin
@nextgensoftwares/folio-plugin-commentsadds Google-Docs-style comments: select text (or a figure, table, equation, code block) and choose Comment, discuss in threads beside the page, resolve, reopen, react and @mention people. Who sees and does what is entirely up to the host, and the reference server enforces the same rules.Render
composed.uiin<FolioView ui={...}>and passcomposed.pageLayersandcomposed.painters: the plugin contributesMod-Alt-M),overlayslot component: the margin rail (cards beside the pages),sidebarslot (all threads, open/resolved filter, search, jump to text),settings: true).How it works
Anchors live in the document, threads don't
A thread is anchored by a
commentmark carrying its id (text) or by thecommentIdsattr of a block (figures, tables, code, equations, callouts;blockTypespicks which). The mark isinclusive: false(typing at a comment's end doesn't extend it) andexcludes: ''(comments overlap). Neither affects layout: highlights are a page layer, and exporters ignore the mark unless you export comments on purpose.Why a mark and not decorations plus position mapping? An anchor that is part of the document survives everything the document survives, with no extra code: edits around and inside it, collab rebasing (it is just another step, so other clients and the server see the same anchors), undo, split/join (each half keeps the mark), copy/paste and reloads. Decorations only live in one editor session: they must be re-derived from stored positions after every reload, mapped through remote steps by hand, and drift whenever a client misses a step. The cost is that comment anchors are document content (so the server's schema must include
commentSchema(), and a reader, who can't edit the document, can't add a mark; see below).The threads themselves (
CommentThread: author, time, body, replies, resolution, reactions) are kept outside the document in aCommentStore, so permissions and storage are separate from the document's.Details worth knowing:
Permissions
Everything is configurable on
commentsPlugin({...})(and live withsetOptions/setUser):readers'hidden': readers see no highlights, rail or sidebar.'view': read-only threads.'comment': they can comment and reply.isReader(user)user.role === 'reader'canView(user)readers; anonymous (user: null) nevercanComment(user)readers: 'comment'canResolve(user, thread)canEdit(comment, user)canDelete(comment, user, thread)canReact(user, thread)A rule that throws denies.
Client rules are only UX
The plugin hides what the policy forbids, but a browser can send anything. Enforce on the server:
@nextgensoftwares/folio-server-node's comment routes evaluate the sameCommentPolicyOptionsagainst the identity youridentifyhook returns, then yourauthorizehook, for every request. Never take the author from the request body.Stores
MemoryCommentStore: in memory; every editor in the tab sharing it sees the same threads and typing indicators.ServerCommentStore: the reference server's REST routes. Writes are optimistic and roll back when refused; other people's changes arrive over a live source (store.setLive(collabClient): the collab socket forwards comment events) or by polling with ETags (pollMs, default 4 s, 4× slower while live).CommentStore(threads,subscribe,apply, optionaltyping/typists/status/error).applyOpis the shared reducer, so any backend can apply and validate ops identically.Server
Routes under
/docs/:id/comments:GET(list, ETag),POST(create),POST /:thread/replies,PATCH /:thread({resolved}or{anchor}),PATCH /:thread/:comment(edit),DELETE /:thread[/:comment],PUT|DELETE /:thread/:comment/reactions/:emoji,POST /typing. Every change is also sent to the document's collaborators as a collabevent({ kind: 'comments' | 'comments-typing' }).insecureHeaderIdentity()trusts anX-Folio-Userheader and exists for demos only.Export
Off by default; turn on with
exportComments: { docx?, pdf?, resolved? }and wire the exporters:comments.xml,commentRangeStart/Endaround the anchored text (across paragraphs too),commentReferenceruns, replies threaded under their root and resolved threads marked done (commentsExtended.xml). Block anchors wrap the block.UI and accessibility
Cards show the author's avatar or initials, name, relative time, body with @mentions, replies, a reply box (when active), resolve/reopen, edit/delete for your own comments and a "…" menu. Clicking highlighted text activates its card; clicking a card (or its quoted text) activates and selects the text. Cards are focusable (
Enteropens the reply box,Escapereturns to the text), menus work with arrow keys, the composer sends withCtrl/⌘+Enter. Styles use Folio's UI tokens (light/dark), and highlights go through the document's colour adapter, so dark document themes adapt them.The rail renders cards only for mounted (visible) pages and stacks them without overlaps around the active card; pages shift left to make room. When there's no room (narrow windows), in reflow and compact layouts, the rail collapses: the active thread opens in a sheet at the bottom of the view and the sidebar lists everything.
Known limits