EmbedPDF

Comments & replies

Any annotation can carry a comment, its contents, and people can reply to it. Together they make a thread: the first comment and its replies, in order. useCommentThreads() gives you every thread in the document, ready for a sidebar:

sidebar.tsx
import { annotationKey, useCommentThreads } from '@embedpdf/react/annotation';

export function Comments() {
  const threads = useCommentThreads();

  return threads.map((thread) => (
    <article key={annotationKey(thread.root.ref)}>
      <p>
        {thread.root.author}, page {thread.pageLabel}: {thread.root.contents}
      </p>
      {thread.replies.map((reply) => (
        <p key={annotationKey(reply.ref)}>
          {reply.author}: {reply.contents}
        </p>
      ))}
    </article>
  ));
}

Loading live preview…

import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import {
  AnnotationLayer,
  annotationKey,
  annotationPlugin,
  useAnnotation,
  useAnnotationState,
  useComments,
  useCommentThreads,
  type CommentThreadView,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';

import './comments.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// On load: a note with two replies on the cover, and a highlight with a comment on page 2.
function AddComments() {
  const annotation = useAnnotation();
  const comments = useComments();
  const ready = useAnnotationState((state) => state.status === 'ready');
  const [cover, second] = usePageList();
  const added = useRef(false);

  useEffect(() => {
    if (!ready || !cover || !second || added.current) return;
    added.current = true;
    void annotation
      .create(cover.ref, {
        subtype: 'text',
        rect: { x: 470, y: 232, width: 20, height: 20 },
        contents: 'Can we shorten the title?',
        color: '#facc15',
      })
      .then(async ({ annotation: note }) => {
        await comments.reply(note.ref, 'Maybe drop "(But they Don’t Have to Be)".');
        await comments.reply(note.ref, 'I like it long. It’s a promise.');
      });
    void annotation.create(second.ref, {
      subtype: 'highlight',
      quadPoints: [
        {
          upperLeft: { x: 57, y: 57 },
          upperRight: { x: 322, y: 57 },
          lowerLeft: { x: 57, y: 129 },
          lowerRight: { x: 322, y: 129 },
        },
      ],
      color: '#ffcd45',
      contents: 'A strong opening.',
    });
  }, [annotation, comments, ready, cover, second]);

  return null;
}

// One thread: the first comment, its replies, a reply box, and a button that shows it.
function Thread({ thread }: { thread: CommentThreadView }) {
  const comments = useComments();
  const annotation = useAnnotation();
  const stage = useStage();
  const [text, setText] = useState('');

  function show() {
    stage.reveal(thread.page, { rect: thread.root.rect });
    annotation.selection.set([thread.root.ref]); // and select it
  }

  return (
    <article className="thread">
      <header className="thread-head">
        <span className="where">Page {thread.pageLabel}</span>
        <button type="button" className="show" onClick={show}>
          Show
        </button>
      </header>
      <p className="comment">
        <strong>{thread.root.author}</strong> {thread.root.contents}
      </p>
      {thread.replies.map((reply) => (
        <p key={annotationKey(reply.ref)} className="comment reply">
          <strong>{reply.author}</strong> {reply.contents}
        </p>
      ))}
      {comments.canReply(thread.root.ref) && (
        <form
          className="reply-form"
          onSubmit={(event) => {
            event.preventDefault();
            if (!text.trim()) return;
            void comments.reply(thread.root.ref, text.trim());
            setText('');
          }}
        >
          <input
            className="field"
            aria-label="Reply"
            placeholder="Reply…"
            value={text}
            onChange={(event) => setText(event.target.value)}
          />
        </form>
      )}
    </article>
  );
}

// Every thread in the document, in reading order.
function Comments() {
  const threads = useCommentThreads();
  const loading = useAnnotationState((state) => state.status === 'loading');

  return (
    <div className="panel threads">
      {loading && <p className="empty">Loading comments…</p>}
      {!loading && threads.length === 0 && <p className="empty">No comments</p>}
      {threads.map((thread) => (
        <Thread key={annotationKey(thread.root.ref)} thread={thread} />
      ))}
    </div>
  );
}

export default function App() {
  return (
    <Viewer
      engine={engine}
      plugins={plugins}
      identity={{ userId: 'u_381', displayName: 'Dana Smith' }}
      initialDocuments={[{ source: ebook }]}
    >
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddComments />
        <div className="viewer">
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <AnnotationLayer />
              </>
            )}
          </Stage>
          <Comments />
        </div>
      </DocumentGate>
    </Viewer>
  );
}

Try it: reply to a thread, or press Show to go to its comment. Add a note on the page: it gets a thread of its own.

Threads come in reading order: by page, then from the top of the page. The list updates by itself when anyone adds, changes or deletes a comment, on this screen or, on a shared document, on someone else’s.

A thread has:

FieldWhat it is
rootThe annotation that starts the thread: a note, a highlight, a shape.
repliesEvery reply, oldest first. Replies to replies are included.
reviewThe review states people set, such as accepted or rejected (see Review states).
pageThe page it’s on.
pageLabelThe page’s label to show, such as 'iv' or '7'.

Reply, edit and delete#

const { annotation: reply } = await comments.reply(thread.root.ref, 'Checked, looks right.');
await comments.setText(reply.ref, 'Checked against the invoice, looks right.');
await comments.delete(reply.ref); // one comment
await comments.deleteThread(thread.root.ref); // the whole thread

A reply always goes to the thread’s first comment, whichever comment you pass. It’s saved the way Acrobat saves replies, so they show up as replies there too.

Deleting the first comment of a thread deletes the whole thread, as Acrobat does. deleteThread() does the same, all or nothing, and tells you what happened: when the thread has a comment you may not delete, such as someone else’s reply, nothing is deleted and failed lists what stopped it.

Review states#

A review state is a verdict on a thread: accepted, rejected, cancelled or completed. Each person sets their own, and a check mark is separate:

await comments.setStatus(thread.root.ref, 'accepted');
await comments.setMarked(thread.root.ref, true); // check it off
 
thread.review.lastChange?.state; // the latest verdict, from anyone
thread.review.byReviewer; // each person's latest verdict
thread.review.markedBy; // who has checked it off

States are saved as the PDF’s own review states, so Acrobat shows them in its comment list.

Loading live preview…

import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import {
  AnnotationLayer,
  annotationKey,
  annotationPlugin,
  useAnnotation,
  useAnnotationState,
  useComments,
  useCommentThreads,
  type CommentThreadView,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';

import './review.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// On load: two notes on the cover, one already accepted.
function AddNotes() {
  const annotation = useAnnotation();
  const comments = useComments();
  const ready = useAnnotationState((state) => state.status === 'ready');
  const cover = usePageList()[0]?.ref;
  const added = useRef(false);

  useEffect(() => {
    if (!ready || !cover || added.current) return;
    added.current = true;
    void annotation.create(cover, {
      subtype: 'text',
      rect: { x: 470, y: 232, width: 20, height: 20 },
      contents: 'Can we shorten the title?',
      color: '#facc15',
    });
    void annotation
      .create(cover, {
        subtype: 'text',
        rect: { x: 280, y: 520, width: 20, height: 20 },
        contents: 'Add the co-author',
        color: '#facc15',
      })
      .then(({ annotation: note }) => comments.setStatus(note.ref, 'accepted'));
  }, [annotation, comments, ready, cover]);

  return null;
}

// A thread's verdict, its check mark, and the buttons the user may use.
function Review({ thread }: { thread: CommentThreadView }) {
  const comments = useComments();
  const ref = thread.root.ref;
  const verdict = thread.review.lastChange?.state ?? 'none';
  const marked = thread.review.markedBy.length > 0;

  return (
    <article className="thread">
      <p className="comment">
        <strong>{thread.root.author}</strong> {thread.root.contents}
      </p>
      <p className="verdict">
        <span className={`state state--${verdict}`}>{verdict}</span>
        {marked && <span className="mark">✓ checked off</span>}
      </p>
      <div className="actions">
        <button
          type="button"
          className="button"
          disabled={!comments.canSetStatus(ref)}
          onClick={() => comments.setStatus(ref, 'accepted')}
        >
          Accept
        </button>
        <button
          type="button"
          className="button"
          disabled={!comments.canSetStatus(ref)}
          onClick={() => comments.setStatus(ref, 'rejected')}
        >
          Reject
        </button>
        <button
          type="button"
          className="button"
          aria-pressed={marked}
          disabled={!comments.canSetMarked(ref)}
          onClick={() => comments.setMarked(ref, !marked)}
        >
          ✓
        </button>
        <button
          type="button"
          className="button"
          disabled={!comments.canDeleteThread(ref)}
          onClick={() => comments.deleteThread(ref)}
        >
          Delete
        </button>
      </div>
    </article>
  );
}

function Reviews() {
  const threads = useCommentThreads();

  return (
    <div className="panel threads">
      {threads.length === 0 && <p className="empty">No comments</p>}
      {threads.map((thread) => (
        <Review key={annotationKey(thread.root.ref)} thread={thread} />
      ))}
    </div>
  );
}

export default function App() {
  return (
    <Viewer
      engine={engine}
      plugins={plugins}
      identity={{ userId: 'u_381', displayName: 'Dana Smith' }}
      initialDocuments={[{ source: ebook }]}
    >
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddNotes />
        <div className="viewer">
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <AnnotationLayer />
              </>
            )}
          </Stage>
          <Reviews />
        </div>
      </DocumentGate>
    </Viewer>
  );
}

Jump to a comment#

The thread’s root has a page and a rect, which is where the view should go:

jump.tsx
import { useAnnotation, type CommentThread } from '@embedpdf/react/annotation';
import { useStage } from '@embedpdf/react/stage';

export function ShowButton({ thread }: { thread: CommentThread }) {
  const stage = useStage();
  const annotation = useAnnotation();

  function show() {
    stage.reveal(thread.page, { rect: thread.root.rect });
    annotation.selection.set([thread.root.ref]); // and select it
  }

  return <button onClick={show}>Show</button>;
}

Methods#

Everything useComments() gives you. It’s the comments part of useAnnotation().

MethodWhat it does
listThreads()Every thread, in reading order. In a component, use useCommentThreads(), which also gives each thread its pageLabel.
getThread(ref)The thread a comment is in, whether you pass its first comment, a reply or a review state, or null. In a component, use useCommentThread(ref).
reply(ref, text)Replies to the thread’s first comment, whichever comment you pass. Resolves { annotation }. Reply, edit and delete
setText(ref, text)Changes a comment’s text
delete(ref)Deletes a comment with its replies. The first comment of a thread deletes the whole thread.
deleteThread(ref)Deletes a whole thread, all or nothing. Resolves { deleted, failed }: when a comment in it may not be deleted, nothing goes and failed lists it.
setStatus(ref, state)Sets the user’s review state on a thread, such as 'accepted' or 'rejected'. Review states
setMarked(ref, marked)Checks a thread off for the user, or clears the check mark
canReply(ref), canSetText(ref), canDelete(ref), canDeleteThread(ref), canSetStatus(ref), canSetMarked(ref)Whether the user may do that to this comment. Permissions

Every method that returns a promise also takes a signal in its options, to cancel it.

Events#

events.tsx
import { useAnnotationEvent } from '@embedpdf/react/annotation';
import { notifyThreadAuthor } from './notifications'; // your own

export function CommentsSidebar() {
  useAnnotationEvent(
    (annotation) => annotation.comments.onThreadChanged,
    ({ rootRef, change }) => change === 'reply' && notifyThreadAuthor(rootRef),
  );

  return <aside>{/* … */}</aside>;
}
EventFires whenCarries
comments.onThreadChangedA thread changed through this viewerrootRef, change

change is 'reply', 'text', 'status', 'marked' or 'deleted'. For every change, including other people’s, use the annotation events; see Events, saving & sharing.

Permissions#

Every comment verb has a check that answers for one comment, so you show only the buttons that work:

permissions.tsx
import { useComments, type CommentThread } from '@embedpdf/react/annotation';

export function ThreadActions({ thread }: { thread: CommentThread }) {
  const comments = useComments();

  return (
    <>
      <button disabled={!comments.canReply(thread.root.ref)}>Reply</button>
      <button disabled={!comments.canDeleteThread(thread.root.ref)}>Delete thread</button>
    </>
  );
}
PermissionWhat it allowsCheck
annotations:createReplying, setting a review state, checking a comment offcanReply(ref), canSetStatus(ref), canSetMarked(ref)
annotations:updateChanging a comment’s textcanSetText(ref)
annotations:deleteDeleting a comment, or every comment in a threadcanDelete(ref), canDeleteThread(ref)

A review state and a check mark are small replies of their own, the way Acrobat stores them, so they need the right to create, not to change. The checks also follow the comment’s own flags: lockedContents stops text edits, and locked stops deleting.

Good to know#

Comments without a note#

Any annotation can start a thread. A highlight with a comment is a thread whose root is the highlight. A plain note (the note tool) is a thread with an icon on the page.

Loading#

The status from useAnnotationState() is 'loading' until every annotation is in. Show a loading state in your sidebar until then, so an empty list doesn’t look like “no comments”.

Reviewing together#

On the local engine, a review stays with the file: the next person sees the comments once they have the downloaded PDF.

Was this page helpful?

Your feedback goes directly to the documentation team.