0

PDF Annotation Selection Menu

Floating actions for a selected annotation — delete, plus whatever else you compose in.

"use client";

import { createPluginRegistration } from "@embedpdf/core";

About

<PdfAnnotationSelectionMenu /> is the companion to <PdfAnnotationToolbar />. The toolbar arms a tool so you can draw; this is what shows up once an annotation exists and you click it. It floats just below the selected annotation as a small toolbar of actions.

Delete is the only action that ships in the box. Everything else is composed in as children, which land to its left — so an app adds duplicate, lock, or <PdfCommentThreadButton /> without this component growing a dependency on each. See Composing actions.

It isn't rendered on its own. The annotation plugin's <PdfAnnotationLayer> owns selection and positioning, and calls this through its selectionMenu render prop — once per annotation, handing over the menuWrapperProps that place and counter-rotate the menu over the page. Your job is to forward those props and the active documentId:

<PdfAnnotationLayer
  documentId={documentId}
  pageIndex={pageIndex}
  selectionMenu={(props) => (
    <PdfAnnotationSelectionMenu documentId={documentId} {...props} />
  )}
/>

Installation

pnpm dlx shadcn@latest add pdfcn/annotation-selection-menu

Usage

Register the annotation plugin.

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.
const plugins = [createPluginRegistration(AnnotationPluginPackage)];

Render the annotation layer and pass the menu to its selectionMenu render prop.

<PdfViewer
  documents={[{ url: "/sample.pdf" }]}
  plugins={plugins}
  className="h-[720px]"
>
  <PdfToolbar>
    <PdfAnnotationToolbar />
  </PdfToolbar>
  <PdfViewerContent
    pageLayers={({ documentId, pageIndex }) => (
      <PdfAnnotationLayer
        documentId={documentId}
        pageIndex={pageIndex}
        selectionMenu={(props) => (
          <PdfAnnotationSelectionMenu documentId={documentId} {...props} />
        )}
      />
    )}
  />
</PdfViewer>

The documentId isn't part of the 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.

Examples

Default

Arm a tool from the toolbar, draw an annotation, then click it: the menu appears below the shape. This example composes in a <PdfCommentThreadButton />, so you can open a conversation on the shape as well as delete it.

"use client";

import { createPluginRegistration } from "@embedpdf/core";

Composing actions

children render to the left of delete, separated from it — the toolbar pattern, so the menu grows without this component learning about every action an app wants:

<PdfAnnotationSelectionMenu documentId={documentId} {...props}>
  <PdfCommentThreadButton
    documentId={documentId}
    annotation={props.context.annotation.object}
  />
</PdfAnnotationSelectionMenu>

Anything goes in there — the menu only supplies the surface and the separator. A rough shape for your own action:

<PdfAnnotationSelectionMenu documentId={documentId} {...props}>
  <Tooltip>
    <TooltipTrigger
      render={
        <Button variant="ghost" size="icon-sm" aria-label="Duplicate">
          <CopyIcon />
        </Button>
      }
    />
    <TooltipContent>Duplicate</TooltipContent>
  </Tooltip>
</PdfAnnotationSelectionMenu>

The card is keyed by the selected annotation's id, so moving to another annotation mounts a fresh one rather than handing a child's half-written draft over.

Per-annotation menus

selectionMenu is called with the annotation, so branching on what's selected is just a conditional. That's how pdfcn's viewer block gives comment pins their own thread card and everything else the generic actions:

selectionMenu={(props) =>
  isPdfCommentPin(props.context.annotation.object) ? (
    <PdfCommentPopover documentId={documentId} {...props} />
  ) : (
    <PdfAnnotationSelectionMenu documentId={documentId} {...props}>
      <PdfCommentThreadButton
        documentId={documentId}
        annotation={props.context.annotation.object}
      />
    </PdfAnnotationSelectionMenu>
  )
}

Respecting locked annotations

Delete reads context.structurallyLocked — set by the PDF's locked flag or a non-interactive annotation — and disables the button rather than letting the edit fail silently. It comes through context untouched; there's nothing to wire.

Composed children get the same context from the selectionMenu payload, so they can gate themselves the same way.

Accessibility

The menu is a small toolbar of icon buttons, each with an aria-label and a tooltip. Composed children are your own controls, so labelling them is on you — match the pattern and they'll read consistently.

Selecting an annotation requires a pointer: selection happens by clicking the annotation on the page, and there's no keyboard equivalent for placing or picking annotations. Once a menu is open, its buttons are ordinary focusable buttons.

API Reference

PdfAnnotationSelectionMenu

A <div> carrying data-slot="pdf-annotation-selection-menu", rendered inside the layer's positioning wrapper. Accepts the layer's render-prop payload plus documentId, and every remaining div prop.

PropTypeDefaultDescription
documentIdstringThe active document. Pass it from the pageLayers scope.
childrenReact.ReactNodeExtra actions, rendered to the left of delete behind a separator.
selectedbooleanFrom the layer. The menu renders null unless the annotation is selected.
contextAnnotationSelectionContextFrom the layer. The selected annotation plus its derived lock flags.
menuWrapperPropsMenuWrapperPropsFrom the layer. Positioning + counter-rotation; spread onto the wrapper as-is.
rectRectFrom the layer. The annotation's viewport-scaled bounding box.

Returns null when nothing is selected or the annotation plugin isn't registered. Delete carries data-slot="pdf-annotation-selection-menu-delete"; it calls the annotation scope's deleteAnnotation and then clears the selection, and is disabled while structurallyLocked is set.

The card stops pointerdown from escaping. The layer sits under a pointer-interaction manager that would otherwise treat a press on the menu as a press on the page, closing whatever a child just opened.