- PDF Viewer
- PDF Toolbar
- PDF More Actions Menu
- PDF Floating Toolbar
- PDF Document Tabs
- PDF View
- PDF Document Grid
- PDF Document Info
- PDF Page Navigation
- PDF Zoom Controls
- PDF Undo/Redo Buttons
- PDF Keyboard Shortcuts
- PDF Bookmark Sidebar
- PDF Thumbnail Sidebar
- PDF Search Panel
- PDF Attachment List
- PDF Annotation Layer
- PDF Annotation Toolbar
- PDF Annotation Inspector
- PDF Annotation Selection Menu
- PDF Annotation Sidebar
- PDF Comment
- PDF Comment Draft
- PDF Comment Button
- PDF Comment Sidebar
- PDF Redaction Toolbar
- PDF Capture Button
- PDF Form Fill Toggle
- PDF Signature Button
- PDF Stamp Button
Commenting end to end — identity, the on-page avatar pin, the thread, where it landed in review, and the card that opens over a pin.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
Commenting is one feature made of a few pieces, and they ship as one component because none of them stands up alone:
<PdfCommentUserProvider>— who is writing, and how any author is displayed.<PdfCommentPin />— the on-page marker, an avatar teardrop in place of the plugin's default sticky-note icon.<PdfCommentThread />— a root comment, its replies, and a reply box.<PdfCommentPopover />— the card that opens over a selected pin, holding the composer or the thread.<PdfCommentThreadButton />— the affordance for commenting on something that isn't a comment: a highlight, a square, an ink stroke.<PdfCommentStatus />— where a thread landed: accepted, rejected, cancelled, completed.
Together they give the flow PDFSlick and Figma have: arm the tool, click the page, and a composer opens right where you dropped the pin. Type the comment, send it, and the card becomes the thread — read, reply, edit, delete, all without leaving the page.
The comments live in the PDF itself. Every message is a Text annotation — the
root pin, and each reply linked to it by inReplyToId — so a thread survives
export and reopens with the document, the same data the sidebar or another
reader would show.
Everything here reads annotation state and renders it. Nothing here overrides
what the annotation plugin does — if you want a click to compose a comment
instead of writing an empty pin the moment it lands, that's
<PdfCommentDraftLayer />, which ships
separately because it changes plugin behaviour rather than dressing it.
Annotation, comment#
A comment is a kind of annotation, so it's easy to treat every annotation as a comment. pdfcn doesn't. The whole component is shaped around the split:
- An annotation is artwork. A highlight, a square, an ink stroke. It's named after the tool that drew it and it lives in the annotation sidebar.
- A comment is a conversation. Somebody wrote it, it's timestamped, it can be replied to, and its whole reason to exist is the words in it. It lives in the comment sidebar.
isPdfCommentPin draws that line: a Text annotation that isn't itself a reply.
Everything else is artwork, no matter what note happens to be attached to it.
The alternative is Acrobat's model, where every markup annotation is a comment and one panel lists all of them — which is why drawing a rectangle there puts an empty row in the comments list. Nutrient, Figma, and Google Docs all draw the line pdfcn draws, and it's the one that makes "no text" rows impossible by construction.
Commenting on an existing annotation replies to it rather than writing into
its contents. A reply carries an author, a timestamp, and a thread; a
contents write is one unattributed string that silently overwrites whatever
another tool put there. See Commenting on an
annotation.
None of this renders until AnnotationPluginPackage is registered on
<PdfViewer /> and a <PdfAnnotationLayer>
is rendering pages. The pin and the popover are both wired through that
layer's render props — they aren't standalone controls.
Review status#
A thread can be ruled on as well as read: Accepted, Rejected, Cancelled, Completed, or back to None. That's PDF's own Review state model, not a vocabulary pdfcn invented, so a status set here is the one Acrobat shows under Set Status.
It's stored the way the format asks for, which is stranger than it looks — the status isn't a field on the annotation:
Beginning with PDF 1.5, annotations may have an author-specific state associated with them. The state is not specified in the annotation itself but in a separate text annotation that refers to the original annotation by means of its IRT ("in reply to") entry.
— ISO 32000-2, 12.5.6.3
So marking a thread resolved writes a reply — authored, dated, and wordless —
carrying /State and /StateModel. Two consequences worth knowing:
- Status is per author. Two reviewers hold two of them, and disagreement is
preserved in the file.
<PdfCommentStatus>updates yours in place instead of appending a second one; a list row shows the thread's latest from anybody, because that's all a row has space for. - Wordless replies aren't messages.
getPdfCommentMessagesfilters them out, so resolving a thread doesn't post an empty bubble or bump the reply count.
Only Text annotations get a state in PDF, so this belongs to threads rather than to the annotation inspector — a square is marked resolved by resolving the conversation on it.
Identity#
A PDF comment stores only a plain author name on the pin — there's nowhere to
put an avatar or a colour — so everything richer is resolved from that name at
render time. <PdfCommentUserProvider> answers the two questions the pin,
popover, and sidebar all ask:
- Who is writing? —
currentUseris the person authoring comments and replies from this viewer. Theirnameis written to each new pin and reply. - How should an author look? —
resolveUserturns a stored name into a display identity: a canonical name, an avatar URL, an accent colour. It's called for every message author, so a directory lookup lights up everyone's avatar at once.
It's entirely optional. With no provider, the current user is "You" and every
author shows initials on a colour derived deterministically from their name — so
the same person keeps the same colour across sessions with no stored state. Add
the provider once you know who's signed in.
Companion components#
Three commenting surfaces stay separate, because each is a choice you make rather than something you inherit:
<PdfCommentButton />arms the built-intextCommenttool so the next click drops a pin.<PdfCommentSidebar />is a newest-first index of every thread; clicking a row selects its annotation and scrolls to it.<PdfCommentDraftLayer />makes a click compose a comment instead of creating one, so nothing enters the document until it's posted.
Installation#
pnpm dlx shadcn@latest add pdfcn/comment
Usage#
Register the annotation plugin with an author.
import { createPluginRegistration } from "@embedpdf/core";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
// Outside the component — a new array identity on every render tears the
// engine down and rebuilds it, losing scroll and zoom state. `annotationAuthor`
// attributes each pin and reply to a name the thread shows.
const plugins = [
createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: "You",
}),
];Render the annotation layer, routing comment pins to the marker and to the thread card.
<PdfViewer
documents={[{ url: "/sample.pdf" }]}
plugins={plugins}
className="h-[720px]"
>
<PdfToolbar>
<PdfCommentButton />
</PdfToolbar>
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
customAnnotationRenderer={({
annotation,
children,
isSelected,
onSelect,
}) =>
isPdfCommentPin(annotation) ? (
<PdfCommentPin
annotation={annotation}
isSelected={isSelected}
onSelect={onSelect}
/>
) : (
children
)
}
selectionMenu={(props) =>
isPdfCommentPin(props.context.annotation.object) ? (
<PdfCommentPopover documentId={documentId} {...props} />
) : null
}
/>
)}
/>
</PdfViewer>The layer renders every annotation through customAnnotationRenderer, so
route comment pins to <PdfCommentPin /> and return children — the plugin's
default rendering — for everything else. Do the same in selectionMenu, handing
non-comment types to
<PdfAnnotationSelectionMenu />
if you render other annotation kinds.
As written, a click with the comment tool armed creates the pin immediately —
that's the annotation plugin's own behaviour. Add
<PdfCommentDraftLayer /> beside the
annotation layer to compose first and create on post instead.
Use isPdfCommentPin rather than a bare type === PdfAnnotationSubtype.TEXT
check: replies are Text annotations too, and they belong in the thread, not on
the page as a second pin on top of the one they answer.
The documentId isn't part of either render-prop payload, so pass it from the
pageLayers scope where you already have it. Everything else — selected,
context, menuWrapperProps, rect — is spread straight through from the
layer.
The selection box#
A selected annotation normally gets a rectangular outline around its bounding box. A comment's box is 24px square and the pin fills it, so that outline lands as four corners poking out from behind a round marker — it reads as a rendering bug rather than as chrome, and the pin already draws its own accent ring.
<PdfAnnotationLayer> drops it for you:
when the only selected annotation is a comment, it sets the outline width to
0, and every other type keeps its box. Nothing to configure.
Under a bare <AnnotationLayer> from the plugin, ask for it yourself — but note
that selectionOutline is layer-wide, so this also drops the box for any other
annotation type on the same layer:
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ width: 0 }}
// …
/>Setting the author#
Wrap the viewer once you know who's signed in, and match currentUser.name to
the annotation plugin's annotationAuthor so a person's pins and replies read
as the same author. Pass resolveUser to light up avatars from your own
directory:
<PdfCommentUserProvider
currentUser={{ name: "Ada Lovelace", avatar: "/avatars/ada.png" }}
resolveUser={(name) => directory[name]}
>
<PdfViewer documents={[{ url: "/sample.pdf" }]} plugins={plugins}>
{/* comment button, layer, sidebar… */}
</PdfViewer>
</PdfCommentUserProvider>resolveUser may return a partial identity — anything you leave out is filled
in from the defaults, so returning just { avatar } keeps the derived name and
colour. Return undefined for a name you don't recognise to fall back entirely.
Identity is declared in two places because two layers write it. currentUser is
pdfcn's: every pin, reply, and status this component writes carries
currentUser.name as its author, so commenting is attributed correctly on its
own. annotationAuthor is the annotation plugin's, and it covers what the plugin
creates without going through pdfcn — a rectangle drawn with the shape tool. Feed
both from one object and a person's rectangle and their reply read as the same
author; let them drift and the same person shows up twice.
Editing never re-attributes. /T is who made the annotation, so pdfcn hands the
existing author back on every update it writes and moves /M instead — see Who
made it.
annotationAuthor is plugin config, and the plugin has no runtime setter for
it — the name is fixed when plugins is built, and changing that array
rebuilds the engine. Where sign-in resolves after the viewer mounts, build the
registration once you know the name, or accept the configured default on marks
drawn before then.
Examples#
Default#
Arm the comment tool, then click the page: a pin drops and the composer opens on
it. Send it and the comment enters the document — click the pin again to read
the thread, reply, and edit or delete any message. The preview above also mounts
<PdfCommentDraftLayer />, so closing
without sending leaves nothing behind.
"use client";
import { createPluginRegistration } from "@embedpdf/core";With a sidebar index#
The sidebar lists every thread. Clicking a row selects its pin and scrolls to it, where the popover opens for reading and replying — the panel indexes, the card converses.
"use client";
import { createPluginRegistration } from "@embedpdf/core";Commenting on an annotation#
Highlights and shapes get a thread too. Compose <PdfCommentThreadButton />
into the
selection menu and a selected
annotation grows a comment button — showing the message count when a
conversation already exists, and opening the thread in a popover:
selectionMenu={(props) =>
isPdfCommentPin(props.context.annotation.object) ? (
<PdfCommentPopover documentId={documentId} {...props} />
) : (
<PdfAnnotationSelectionMenu documentId={documentId} {...props}>
<PdfCommentThreadButton
documentId={documentId}
annotation={props.context.annotation.object}
/>
</PdfAnnotationSelectionMenu>
)
}The comment becomes a Text annotation linked to the highlight by inReplyToId —
the PDF format's own threading mechanism (ISO 32000-1 §12.5.6.2), where the
parent may be any markup annotation. So it carries an author and a timestamp, it
can be replied to in turn, and it reopens in Acrobat as a reply chain.
Nothing about the highlight changes. It's still "Highlight" in the annotation
sidebar, and the conversation shows up in the comment sidebar like any pin —
labelled on highlight so the thread stays attached to its subject.
What pdfcn deliberately doesn't do is write into the annotation's own
contents. That field holds one unattributed, undated string, and Acrobat and
Preview both already use it for imported notes — so authoring into it means
silently destroying somebody else's. pdfcn reads contents (it surfaces as a
thread's opening message) and never writes it.
Respecting locked annotations#
The thread reads the document's modify permission through
useDocumentPermissions: when the document forbids
modifying annotations, the reply box and every message's edit/delete menu are
hidden, leaving a read-only thread rather than controls that fail silently.
Accessibility#
Placing a comment moves focus into the composer's field. Sending is an ordinary
button, enabled only when there's text; Enter sends, Shift + Enter breaks
the line, and Escape cancels. Cmd/Ctrl + Enter sends as well, for
fingers trained on GitHub-style composers. While an IME candidate list is open,
Enter picks the candidate instead of sending. Each message's edit/delete lives
behind a labelled overflow menu.
Selecting a pin requires a pointer, since selection happens by clicking the pin on the page — there's no keyboard equivalent for placing or picking annotations.
API Reference#
PdfCommentUserProvider#
| Prop | Type | Default | Description |
|---|---|---|---|
currentUser | PdfCommentUser | { name: "You" } | The person authoring comments here. Their name is written to each pin and reply. |
resolveUser | (name: string) => Partial<PdfCommentUser> | undefined | — | Map a stored author name to a richer identity. Merged over the defaults; omit anything unknown. |
usePdfCommentUser#
Reads identity from the nearest provider, or the defaults when there's none.
Returns { currentUser, resolveUser }, where resolveUser(name) always yields a
complete PdfCommentUser (defaults filled in).
PdfCommentUser#
interface PdfCommentUser {
/** Display name, and the value written to the annotation's `author`. */
name: string;
/** Optional avatar image URL. Falls back to coloured initials. */
avatar?: string;
/** Optional accent colour. Defaults to a deterministic colour from the name. */
color?: string;
}PdfCommentAvatar#
An <Avatar /> for a resolved author — their photo when one is supplied,
otherwise their initials on the resolved colour. Takes user: PdfCommentUser
plus every <Avatar /> prop.
PdfCommentPin#
A <button> carrying data-slot="pdf-comment-pin" and a data-selected
attribute when selected. Accepts every <button> prop (except onSelect, which
is typed for the layer), plus:
| Prop | Type | Default | Description |
|---|---|---|---|
annotation | PdfAnnotationObject | — | From the layer. The comment pin to render; its author resolves the avatar. |
isSelected | boolean | — | From the layer. Draws the accent ring and holds the hover preview shut. |
onSelect | (event: React.MouseEvent) => void | — | From the layer. Selects the annotation; wired to the button's onClick. |
The annotation plugin still owns the pin's position, size, and selection; the marker just fills the pin's box, so it tracks the annotation as the page scrolls and scales with zoom.
The hover preview#
Hovering (or focusing) a pin that already has text grows the marker itself into
a preview — author, relative time, and the body clamped to two lines. There's no
second element and no portal: the avatar and the preview are one box carrying
data-slot="pdf-comment-pin-card", so the two states read as one object
changing size rather than a card appearing beside a dot.
That identity is what makes it feel solid, and it's why there's no animation
library here. The box is anchored top-0 left-0 and only padding and
box-shadow transition, over 75ms. Width and height stay intrinsic, so they
snap — at that duration nobody reads the snap as a jump, and an animated width
would reflow the text on every frame instead.
The top-left corner is the one point that never moves, and it's squared off
(rounded-tl-none) so the marker reads as a teardrop pointing at it. That
corner is the annotation rect's origin — the only point the layer keeps nailed
to the page as you zoom, since a comment is a noZoom annotation whose box
stays 24px on screen at every scale while every other corner drifts.
The preview stays shut while the pin is selected, since the popover is already
showing the whole thread, and on a pin with no text there's nothing to show. It
carries data-slot="pdf-comment-pin-preview", so its width or clamp is
adjustable from the pin:
<PdfCommentPin
annotation={annotation}
isSelected={isSelected}
onSelect={onSelect}
className="**:data-[slot=pdf-comment-pin-preview]:w-56"
/>The card is paper-coloured in both light and dark mode, and so is the halo
around it. A pin lies on the page, and paper doesn't invert with the colour
scheme — a --popover card would go near-black in dark mode on a page that
stayed white, and the halo's job is to hold the marker off whatever ink it
landed on, which means being the paper. Both read --pdf-page; see
Recolouring the paper.
Selection#
Selection draws a paper-coloured gap and then an accent band outside it, both
following the teardrop. The accent is var(--pdf-annotation-selection) — the
variable <PdfAnnotationLayer /> draws
the rest of its selection chrome in — so setting it once anywhere above the
viewer recolours the pin along with everything else. It's the only chrome a
selected pin gets; see the selection box above.
Cursors come from two places. At rest the pin shows a pointer, because a click
is all it accepts. Once selected the plugin lays its own drag surface over the
marker and that surface carries cursor: move, so the drag cursor appears
exactly when dragging is possible — the plugin only enables dragging on the
selected annotation.
PdfCommentPopover#
A <div> carrying data-slot="pdf-comment-popover", rendered inside the layer's
positioning wrapper. Accepts the layer's render-prop payload plus documentId,
and every remaining div prop.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The active document. Pass it from the pageLayers scope. |
selected | boolean | — | From the layer. The card renders null unless the pin is selected. |
context | AnnotationSelectionContext | — | From the layer. The selected annotation and its derived flags. |
menuWrapperProps | MenuWrapperProps | — | From the layer. Positioning + counter-rotation; spread onto the wrapper as-is. |
rect | Rect | — | From the layer. The pin's viewport-scaled bounding box, used to offset the card. |
Returns null when nothing is selected or the annotation plugin isn't
registered. The card is keyed by the selected pin's id, so switching selection
starts a new card rather than handing the previous pin's half-written draft to
the next one.
Comments and replies are written through the annotation scope's
createAnnotation/updateAnnotation; deleting a message calls
deleteAnnotation.
Whether the card shows the composer or the thread is read from the pin's text on every render rather than latched when the card opens. The layer re-renders this card from plugin state on every change, so undoing the first comment puts the card back into the composer instead of leaving a thread wrapped around an empty root.
This card never deletes a pin. With
<PdfCommentDraftLayer /> mounted an empty
one isn't something pdfcn writes at all, so a pin with nothing on it was either
emptied by an undo or authored somewhere else.
Either way it's the document's, not a leftover of ours, and the card offers to
fill it in rather than removing it out from under whoever placed it. Deleting is
the trash button's job.
PdfCommentThread#
A <div> carrying data-slot="pdf-comment-thread". Accepts every <div> prop,
plus:
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the thread reads and writes. |
entry | SidebarAnnotationEntry | — | The root annotation and its replies, from findPdfCommentThread. |
Returns null when the annotation plugin isn't registered. The reply box and
each message's edit/delete menu are hidden when the document forbids modifying
annotations. See Permissions for the full model.
The root renders as a message only when it has something to say — a pin's own
text, or a contents note some other reader left on a shape. A square you drew
is artwork, so the thread opens straight at its replies. Editing and deleting
are offered on a pin's root and on replies; a shape's root isn't a message and
isn't edited from here.
Rendering a thread outside the popover — in a panel of your own, say — means resolving its entry through the shared finder, so your panel, the popover, and the sidebar always agree:
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
function Thread({ documentId, id }: { documentId: string; id: string }) {
const { state } = useAnnotation(documentId);
const entry = findPdfCommentThread(state, id);
if (!entry) return null;
return <PdfCommentThread documentId={documentId} entry={entry} />;
}PdfCommentThreadButton#
The comment affordance for an annotation that isn't a comment. A <Button>
carrying data-slot="pdf-comment-thread-button" that opens the annotation's
thread in a popover. Accepts every <Button /> prop except children, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the thread reads and writes. |
annotation | PdfAnnotationObject | — | The annotation being discussed — the thread's root. |
With no conversation yet it's an icon button labelled "Comment on this
annotation"; once there is one it shows the message count. Composed into
<PdfAnnotationSelectionMenu /> —
see Commenting on an annotation.
Returns null when the annotation plugin isn't registered or the annotation
isn't in plugin state yet. Replies are written through the annotation scope's
createAnnotation as Text annotations carrying inReplyToId and
replyType: Reply, positioned on the root's rect.
PdfCommentMessage#
One message: avatar, author, relative time, body, and an optional corner
action (typically the overflow menu). Takes comment: PdfAnnotationObject, an
optional action node, and every <div> prop. The body carries
data-slot="pdf-comment-message-body", so an ancestor can truncate or restyle
it without replacing the message.
PdfCommentActions#
The edit/delete overflow menu. Takes onEdit: () => void and
onDelete: () => void.
PdfCommentComposer#
A growing message field with a send button. Manages its own text and calls
onSubmit with the trimmed value.
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (text: string) => void | — | Called with the trimmed text on send. |
onCancel | () => void | — | Called on Escape, when provided. |
value | string | — | Controls the field. Uncontrolled when omitted. |
onValueChange | (text: string) => void | — | Called as the field changes, for the controlled state. |
placeholder | string | "Reply…" | Field placeholder and aria-label. |
submitLabel | string | "Send" | Accessible label for the send button. |
autoFocus | boolean | — | Focus the field on mount. |
Pass value and onValueChange to hoist the text somewhere that outlives the
field. <PdfCommentDraftLayer /> does
exactly that, because an unposted comment has to survive its card being closed. A controlled composer doesn't clear itself
on send — whoever owns the value decides what happens to it.
PdfCommentStatus#
The review status control: a quiet icon button while a thread is untriaged, wearing the verdict once someone has ruled on it. Rendered in the header of both comment cards.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the thread belongs to. |
entry | SidebarAnnotationEntry | — | The thread to read and write status on. |
Everything else forwards to <Button>.
On a read-only document it degrades to <PdfCommentStatusBadge> — a review
still shows where it landed, it just can't be moved.
PdfCommentStatusBadge#
The same status as a read-only chip, for a list row or a header. Takes
state?: PdfAnnotationState and every <span> prop, and renders nothing when
the thread is untriaged so an unreviewed list stays quiet.
Model helpers#
The functions that draw the annotation/comment line. They're plain functions over plugin state, so anything reading annotations can apply the same rule rather than re-deriving it:
| Function | Returns | Description |
|---|---|---|
isPdfCommentPin(annotation) | boolean | Whether an annotation is a comment pin — a Text annotation that isn't itself a reply. |
isPdfCommentThread(entry) | boolean | Whether a sidebar entry is a conversation — has anyone actually spoken here? |
getPdfCommentThreads(state) | SidebarAnnotationEntry[] | Every conversation in the document. What the comment sidebar lists. |
findPdfCommentThread(state, id) | SidebarAnnotationEntry? | The thread anchored to one annotation, spoken in or not — what the composer needs. |
getPdfCommentMessages(entry) | PdfAnnotationObject[] | Everything anyone actually said, in order — status replies filtered out. |
countPdfCommentMessages(entry) | number | Messages in a thread: the root's own note, plus every reply. |
getPdfCommentLead(entry) | PdfAnnotationObject? | The message a thread opens with — the root's note, or the first reply if it has none. |
findPdfCommentStatus(entry, author?) | PdfTextAnnoObject? | The status reply — that author's, or the thread's latest. |
A pin nobody has typed into yet isn't a thread, so an empty one doesn't show up
in the sidebar however it got there.
<PdfCommentDraftLayer /> keeps pdfcn from
making those; this keeps them from being listed as conversations when they arrive
from elsewhere.
Presentation helpers#
What <PdfCommentAvatar /> uses to stand in for a person with no picture.
Exported so a row of your own matches:
| Function | Returns | Description |
|---|---|---|
getPdfCommentInitials(name) | string | First letter of the first and last word, or the first two of a single name. |
getPdfCommentUserColor(name) | string | A deterministic accent colour for a name, stable across sessions and machines. |
Timestamps come from formatTimestamp(date) in @/registry/lib/pdf-format —
"just now", "5 minutes ago", "2 days ago". It sits in lib because the
annotation panel dates its rows the same way and shouldn't have to pull in the
whole comment module to do it.
On This Page
AboutAnnotation, commentReview statusIdentityCompanion componentsInstallationUsageThe selection boxSetting the authorExamplesDefaultWith a sidebar indexCommenting on an annotationRespecting locked annotationsAccessibilityAPI ReferencePdfCommentUserProviderusePdfCommentUserPdfCommentUserPdfCommentAvatarPdfCommentPinThe hover previewSelectionPdfCommentPopoverPdfCommentThreadPdfCommentThreadButtonPdfCommentMessagePdfCommentActionsPdfCommentComposerPdfCommentStatusPdfCommentStatusBadgeModel helpersPresentation helpers