noteloom

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:

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

jsx
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 }] }.

Collaboration scope: a thread's own metadata (text, author, replies, resolved flag) is fully collaboration-aware — it broadcasts live to connected peers. The highlighted range it's anchored to is local-only in collaboration for v1: a newly-joining peer sees it correctly, but an already-connected peer won't see a brand-new highlight appear live until their next resync — the same scope every other range-based formatting operation (bold, italic, highlight) already has, per Live collaboration.
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.