0

PDF Comment Draft

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 textComment tool'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 draftDraft with text
Click the pageplaces a draftmoves it there, text and all
Drag the pinmoves itmoves it
Esc, close, or awaydraft is gonecard closes, pin parks
Click the parked pincard reopens, still typed
Senddisabledone 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

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.

PropTypeDefaultDescription
documentIdstringThe document the draft belongs to.
pageIndexnumberThe 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:

SlotWhat it is
pdf-comment-draft-pinThe marker. A <button> labelled "Unposted comment"; data-open while the card is.
pdf-comment-draftThe 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.