- 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
Compose a comment before it exists — a pin and a composer held in the viewer, written to the document only when you post it.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
<PdfCommentDraftLayer /> changes what a click does. With the comment tool armed
and this layer mounted, clicking the page opens a composer on a dashed pin —
nothing is in the document yet. Post it and the Text annotation is written, once.
This is the one piece of pdfcn's commenting that isn't a wrapper over embedpdf.
Everything in pdfcn/comment reads annotation
state and renders it; this layer withholds something from the plugin, and it
ships separately for that reason. Leave it out and you get the annotation
plugin's own behaviour, unchanged.
Nothing is written until it's said#
On its own, the annotation plugin commits the annotation on pointer-down: the click is the comment, and the composer edits something that already exists. Both models look identical for the click that ends in a comment. They differ on every click that doesn't:
- Ten exploratory clicks leave ten blank sticky notes in the file, and whoever opens it next in Acrobat finds them.
- Undo walks back through pins nobody meant to make.
- The comment sidebar has to filter out annotations that aren't really conversations, forever, because the format now says they are.
PDF has no way to record the difference. /Contents is optional on a markup
annotation, so an empty /Text is perfectly legal — it just means "a sticky note
with nothing on it", which is not what someone mid-sentence has. What they have
is interaction state, and interaction state belongs in the viewer.
The obvious alternative — create the pin on pointer-down and delete it again if nobody types — looks the same from outside and is worse underneath, because every abandoned click writes a create and a delete into history.
What it costs#
Worth knowing before you install it:
- A parked draft lives in the browser tab and nowhere else, so a reload loses it. The document doesn't know about it, and an unsent message behaves this way everywhere else.
- Mounting the layer swaps the built-in
textCommenttool's pointer handler for an inert one, and puts the original back when the last page unmounts. See How it works. - One draft per document at a time. Clicking again moves the one you have.
Installation#
pnpm dlx shadcn@latest add pdfcn/comment-draft
Usage#
Render it inside pageLayers, once per page, beside
<PdfAnnotationLayer>:
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<>
<PdfCommentDraftLayer documentId={documentId} pageIndex={pageIndex} />
<PdfAnnotationLayer documentId={documentId} pageIndex={pageIndex} />
</>
)}
/>It belongs everywhere <PdfCommentPin /> does
and nowhere it doesn't: the two are the same marker before and after posting, and
mounting one without the other means a draft that doesn't look like the pin it
becomes.
The rules#
Every one of these follows from a draft not being a thing the file can hold:
| With the comment tool armed… | Empty draft | Draft with text |
|---|---|---|
| Click the page | places a draft | moves it there, text and all |
| Drag the pin | moves it | moves it |
| Esc, close, or away | draft is gone | card closes, pin parks |
| Click the parked pin | — | card reopens, still typed |
| Send | disabled | one annotation, one undo step, with a note |
| Discard (the trash button) | — | draft is gone |
Two of those deserve stating plainly. Clicking again moves the draft rather than starting a second one: one unsent comment at a time, like one unsent message, so a run of clicks can't leave a trail. And closing never discards words: a draft with text parks as a dashed pin instead of vanishing, because dismissing a card should not be how you lose a paragraph. Throwing it away is a separate, deliberate button.
Posting writes the pin the plugin's own handler would have written — same
defaults, same 24px box, same noZoom flags — plus the author and the text, in a
single createAnnotation. The history stack gets one entry, and one undo takes
the whole comment back.
Escape#
Esc closes the card wherever focus happens to be — in the field, on the pin after a drag, or nowhere at all — exactly as it does on a posted comment's card. The two cards look alike, so a key that dismisses one has to dismiss the other.
Getting it takes a small detour, because the plugin can't help. A posted card
closes because Esc clears the plugin's selection and the card is a
function of it; a draft is in no plugin state, so there is nothing for
deselectAnnotation to clear. Instead the layer registers itself while the card
is open and the
Deselect command dispatches
pdfcn:comment-draft:close, which the layer answers by closing — the same
function the ✕ button calls.
Routing it through the preset rather than handling it on the card is what makes it focus-independent, and it keeps the unwind order honest: with a tool armed, the first Esc drops the tool and the second closes the card, which is what a posted comment already did. Typing into the field is still handled locally — the commands plugin ignores keys aimed at an input — so both routes end in the same place.
Without pdfcn/keyboard-shortcuts
installed, Esc still closes the card from inside the field, and you
can dispatch PDF_COMMENT_DRAFT_CLOSE_EVENT on window from a binding of your
own.
Dragging#
A draft pin drags, in any interaction mode, exactly as a selected comment pin does. It has to: it looks like a pin, so nobody is going to reason "this one isn't in the file yet, so it must be immovable." An affordance that's there for one pin and missing for an identical-looking one reads as a bug.
The plugin can't do this for us — its drag moves a selected annotation in plugin
state, and a draft is in neither — so the pin moves itself, over the plugin's
own restoreOffset rather than a second idea of how a page maps to a screen. A
move under 4px stays a click, so clicking a parked pin opens it instead of
nudging it.
How it works#
Mounting the layer swaps the built-in textComment tool's pointer handler for
an inert one, and puts the original back when the last page unmounts. The tool
is otherwise untouched: same id, same defaults, same cursor, same toolbar
entry and shortcut. It has to be a real handler rather than no handler — the
plugin falls back to the built-in factory for the tool's annotation type when
a tool has none, which would restore the very behaviour we're replacing.
Everything the layer does is public API: addTool to swap the handler (replacing
by id is what it's for), registerHandlers on the interaction manager to put a
draft where the create used to be, createAnnotation to post, and restoreOffset
for the drag geometry. Nothing reaches into plugin internals.
Ideally the plugin would offer this as a tool behavior flag — something like
createOnCommit: false — so compose-then-post wouldn't mean unpicking a built-in
tool. If that lands upstream, most of this component goes away and what's left is
the card.
Examples#
Default#
Arm the comment tool, click a page, and type. Nothing reaches the document until you send.
"use client";
import { createPluginRegistration } from "@embedpdf/core";The whole commenting surface#
The draft layer is one of four pieces, and they're meant to be mounted together: the button arms the tool, the draft layer catches the click, the annotation layer draws what's been posted, and the sidebar indexes it.
<PdfCommentUserProvider currentUser={{ name: "You" }}>
<PdfViewer documents={[{ url: "/sample.pdf" }]} plugins={plugins}>
<PdfToolbar>
<PdfCommentButton />
</PdfToolbar>
<div className="flex min-h-0 flex-1">
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<>
<PdfCommentDraftLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ width: 0 }}
customAnnotationRenderer={renderCommentPin}
selectionMenu={renderCommentPopover}
/>
</>
)}
/>
<PdfCommentSidebar className="w-80 border-l" />
</div>
</PdfViewer>
</PdfCommentUserProvider>renderCommentPin and renderCommentPopover stand in for the two render
callbacks written out in full under
PDF Comment. Without them a posted comment
draws as a plain Text annotation rather than as the pin the draft turned into.
Showing an unsent draft elsewhere#
usePdfCommentDraft reads the draft from anywhere under the viewer, for chrome
that has to acknowledge it — a count in a panel header, a guard before closing
the tab:
function UnsentBadge({ documentId }: { documentId: string }) {
const draft = usePdfCommentDraft(documentId);
if (!draft?.text.trim()) return null;
return <Badge variant="secondary">1 unsent</Badge>;
}It subscribes through useSyncExternalStore, so it tracks the draft across
pages — including one placed on a page that's since scrolled out of view.
Accessibility#
Escape on a draft is a close, not a discard — it parks the pin with your words
intact, and the trash button beside it is the one that throws them away. It works
wherever focus is, the same as on a posted comment's card; see
Escape.
A draft pin announces itself as "Unposted comment" and carries aria-expanded,
so it reads as the disclosure it is rather than as a comment that exists.
Placing a comment moves focus into the composer's field. Enter sends,
Shift + Enter breaks the line, Cmd/Ctrl + Enter sends as well, and
while an IME candidate list is open Enter picks the candidate instead.
Placing and dragging a draft require a pointer, the same as selecting any annotation on the page.
API Reference#
PdfCommentDraftLayer#
The page-sized layer that holds the unposted comment.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the draft belongs to. |
pageIndex | number | — | The page this instance places and renders on. |
Everything else forwards to the layer <div>, which carries
data-slot="pdf-comment-draft-layer". It's pointer-events: none and covers the
page — placing is the interaction manager's job, not the element's — so it never
sits between a click and whatever is under it.
Renders nothing unless the draft is on this page, which is why every page can mount one. The pieces it renders when it is:
| Slot | What it is |
|---|---|
pdf-comment-draft-pin | The marker. A <button> labelled "Unposted comment"; data-open while the card is. |
pdf-comment-draft | The card, holding the header and the composer. |
The pin is <PdfCommentPin />'s chrome exactly,
wearing a dashed accent outline instead of a solid one — the same object in two
states, rather than two objects. It tracks zoom and page rotation on the same
terms a posted pin does: 24px on screen at every scale, upright however the page
is turned.
Both slots carry data-no-interaction, which keeps the interaction manager off
them: without it, a click on the card or on the pin would also land on the page
and move the draft out from under the cursor. The pin is cursor-move at rest
and touch-none, so a touch drag moves the pin rather than scrolling the page.
The card is hidden when the document forbids modifying annotations.
usePdfCommentDraft#
usePdfCommentDraft(documentId) returns the document's unposted comment, or
undefined. Subscribed through useSyncExternalStore, so it re-renders on every
change to the draft — including from another page's layer.
interface PdfCommentDraft {
/** The page it was placed on. */
pageIndex: number;
/** Where the pin will go, in unrotated page coordinates. */
rect: Rect;
/** What's been typed so far. Survives the card being closed. */
text: string;
/** Whether the card is open. Closed with text is a parked draft. */
open: boolean;
}Note what isn't there: an annotation id. A draft has no counterpart in the plugin or the file, which is where every difference between a draft pin and a posted one comes from.
One draft per document, kept in module state rather than React state: the handler
that places it is registered per page and the card that edits it renders on one
page, so a useState in either would be unmounted by the very scroll that takes
you to where you want to comment. Two documents open side by side each keep their
own.