Comments
Select a range, leave a comment on it; click or hover the highlighted text later to view, reply, resolve, or delete it — the same interaction model as Google Docs or Notion, built on the same primitives everything else in noteloom uses (no separate comments engine bolted on).
The built-in UI (zero comment-authoring code of your own)
Pass commentAuthorId — the current user's id — to <NoteloomEditor> and the whole experience just works:
<NoteloomEditor editor={editor} commentAuthorId={currentUser.id} showCommentsPanel />- The floating format toolbar's Comment button opens a small inline composer and creates the comment on submit.
- Clicking (or hovering) any highlighted comment opens a popover with the thread's messages and Reply/Resolve/Delete.
showCommentsPanel(optional) adds a right-side panel listing every thread in the document, unresolved first — a persistent overview alongside the inline popovers, not instead of them.
Every reply/new comment composed through any built-in surface is attributed to commentAuthorId. Omit it and the toolbar's Comment button disappears; viewing/resolving/deleting existing comments still works (no identity needed for those), just not replying.
Full control (bring your own UI)
Pass onComment instead of commentAuthorId — it's called with the selected range, and you decide what happens next:
import { addComment, replyToComment, resolveComment, deleteComment, useComments, resolveMultiRunSelection } from 'noteloom';
<NoteloomEditor
editor={editor}
onComment={(range) => {
const text = window.prompt('Comment text?');
if (text) addComment(editor.store, range, { authorId: currentUser.id, text });
}}
/>;
// Outside the floating toolbar entirely, resolve the selection yourself:
function AddCommentButton({ store }) {
function handleClick() {
const range = resolveMultiRunSelection(); // { blockId, startRunId, startOffset, endRunId, endOffset }
if (!range) return; // no non-collapsed selection
addComment(store, range, { authorId: currentUser.id, text: 'Can we tighten this up?' });
}
return <button onClick={handleClick}>Add comment</button>;
}
// A hand-rolled list, using useComments() directly instead of CommentsPanel:
function CommentsSidebar({ store }) {
const comments = useComments();
return (
<ul>
{comments.map((thread) => (
<li key={thread.id}>
{thread.messages.map((m) => <p key={m.id}>{m.authorId}: {m.text}</p>)}
<button onClick={() => replyToComment(store, thread.id, { authorId: currentUser.id, text: '...' })}>Reply</button>
<button onClick={() => resolveComment(store, thread.id, !thread.resolved)}>{thread.resolved ? 'Reopen' : 'Resolve'}</button>
<button onClick={() => deleteComment(store, thread.id)}>Delete</button>
</li>
))}
</ul>
);
}onComment always takes priority over commentAuthorId's built-in composer, so the two never fight over the same button. A comment thread is { id, blockId, anchorRunIds, resolved, messages: [{ id, authorId, text, createdAt }] }.
thread.anchorRunIds is a creation-time hint only, not re-validated after a later formatting edit reshapes that range — to reliably find where a comment's highlight actually lives, check which runs' marks.commentIds include it instead.