Files
"use client";
import { createPluginRegistration } from "@embedpdf/core";
import type { PluginBatchRegistrations } from "@embedpdf/core/react";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
import { PanelLeftIcon, PanelRightIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { PdfAnnotationInspector } from "@/components/pdf-annotation-inspector";
import { PdfAnnotationLayer } from "@/components/pdf-annotation-layer";
import { PdfAnnotationSelectionMenu } from "@/components/pdf-annotation-selection-menu";
import { PdfAnnotationSidebar } from "@/components/pdf-annotation-sidebar";
import { PdfAnnotationToolbar } from "@/components/pdf-annotation-toolbar";
import { PdfBookmarkSidebar } from "@/components/pdf-bookmark-sidebar";
import {
PdfCommentPin,
PdfCommentPopover,
PdfCommentThreadButton,
type PdfCommentUser,
PdfCommentUserProvider,
isPdfCommentPin,
} from "@/components/pdf-comment";
import { PdfCommentDraftLayer } from "@/components/pdf-comment-draft";
import { PdfCommentSidebar } from "@/components/pdf-comment-sidebar";
import {
PdfFloatingToolbar,
PdfFloatingToolbarSeparator,
} from "@/components/pdf-floating-toolbar";
import { PdfMoreActionsMenu } from "@/components/pdf-more-actions-menu";
import { PdfPageNavigation } from "@/components/pdf-page-navigation";
import { PdfSearchPopover } from "@/components/pdf-search-panel";
import { PdfShortcutsDialog } from "@/components/pdf-shortcuts-dialog";
import { PdfThumbnailSidebar } from "@/components/pdf-thumbnail-sidebar";
import {
PdfToolbar,
PdfToolbarGroup,
PdfToolbarSeparator,
} from "@/components/pdf-toolbar";
import {
PdfExportButton,
PdfOpenFileButton,
PdfPanToggle,
PdfPointerToggle,
} from "@/components/pdf-toolbar-controls";
import { PdfUndoRedoButtons } from "@/components/pdf-undo-redo";
import { PdfViewer } from "@/components/pdf-viewer";
import { PdfViewerContent } from "@/components/pdf-viewer-content";
import { PdfZoomControls } from "@/components/pdf-zoom-controls";
import { createKeyboardShortcutsRegistration } from "@/lib/pdf-keyboard-shortcuts";
type LibraryTabId = "annotations" | "pages" | "outline";
type InspectorTabId = "properties" | "comments";
/**
* Who this session writes as. Two layers ask for it and neither can read the
* other's answer: `annotationAuthor` is plugin config, resolved when the
* registrations below are built, and `currentUser` is React context that only
* exists once the tree mounts. Config can't reach into context, and a provider
* can't feed config without rebuilding the engine — so the two are held together
* here instead, by naming the person once and spending the name twice.
*
* Let them drift and the same person shows up twice: their rectangle signed one
* way, their reply another.
*/
const CURRENT_USER: PdfCommentUser = { name: "You" };
/**
* Annotation is a heavy-tier plugin `<PdfViewer>` doesn't register on its own,
* so the app opts in here — as does the commands plugin, which is what binds the
* keyboard. Resolved once at module scope — a fresh array on every render would
* hand `<PdfViewer>` a new `plugins` identity and tear the engine down.
*/
const ANNOTATOR_PLUGINS: PluginBatchRegistrations = [
// Covers what the plugin creates on its own — a rectangle from the shape tool,
// which never passes through the comment components.
createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: CURRENT_USER.name,
}),
// Both presets: the browser-viewer chords, plus the design-tool layer this app
// is shaped around — a letter per tool, Backspace to delete, arrows to nudge.
createKeyboardShortcutsRegistration(),
];
/**
* Below this width there isn't room for two panels and a page between them, so
* the panels stop sharing the row and overlay the canvas instead — reachable
* from the same two toolbar toggles. The tool pill never sheds: an annotator
* without visible tools is just a reader. It floats over the canvas rather than
* taking a column of its own, so it costs the layout nothing at any width.
*/
const COMPACT_WIDTH = 840;
/**
* An annotation-first PDF editor assembled entirely from pdfcn's registry
* components, laid out the way a design tool is rather than the way a reader
* is. `<PdfViewerApp>` is the reader — it follows PDFSlick, keeps annotating
* behind a toolbar cluster, and opens on thumbnails. This one inverts that:
* marking up the document is the primary job, so the surface that supports it
* is on screen from the first frame.
*
* Three regions, after Figma:
*
* - **A floating tool pill** over the canvas, holding the whole mutually
* exclusive set of modes — pointer, pan, and the drawing tools — in the order
* a design tool puts them. It's inside the content panel rather than the body,
* so it stays centred on the page as panels open and close, and it's never
* collapsed, resized, or shed by a container query. Whatever the window does,
* the tools are where you left them.
* - **A left panel of what's in the document** — the annotations index, plus
* pages and the outline as the other two ways of finding your place. Figma's
* layers panel, and the answer to *what is on this document*.
* - **A right panel of what the selection looks like** — the style inspector,
* plus comment threads. Figma's properties panel, and the answer to *what
* does the selected thing look like*.
*
* Both panels are docked and open on load. That's the whole difference between
* a block that composes the annotation components and one that showcases them:
* the inspector is only useful if you can see it change when you select
* something.
*
* Each panel switches with a tab strip. The two list panels ship a title bar
* that only repeats what the active tab already says, so it's hidden through
* its `data-slot`; the inspector's names *what is being edited* — "Square",
* "3 selected" — which the tab can't, so it stays. Figma's right panel reads
* the same way: tabs, then the selection.
*
* Both `<PdfOpenFileButton>` and `<PdfMoreActionsMenu>` are given
* `closeExisting`, so opening a new file always replaces the current document
* rather than accumulating hidden ones — this app edits one document at a time.
*
* The root fills its parent, so give it a sized container (a viewport-height
* flex cell, or an explicit height).
*/
export function PdfAnnotatorApp({
className,
plugins,
...props
}: Omit<React.ComponentProps<typeof PdfViewer>, "children">) {
const [libraryTab, setLibraryTab] =
React.useState<LibraryTabId>("annotations");
const [libraryOpen, setLibraryOpen] = React.useState(true);
const [inspectorTab, setInspectorTab] =
React.useState<InspectorTabId>("properties");
const [inspectorOpen, setInspectorOpen] = React.useState(true);
// Track the viewer's own width so the layout responds to the space it's given
// rather than the page's — the block is embedded (iframe previews, a
// consumer's column) as often as it's full-screen, so a viewport breakpoint
// would misfire. Crossing the threshold resets both panels to their default
// for that size; within a size the user's own toggles stand.
//
// A callback ref (not an effect) wires the observer, since `<PdfViewer>`
// mounts its children only once the engine is ready — the node this measures
// doesn't exist yet when a mount effect would run.
const [compact, setCompact] = React.useState(false);
const compactRef = React.useRef(compact);
const observerRef = React.useRef<ResizeObserver | null>(null);
const bodyRef = React.useCallback((el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!el) return;
const measure = () => {
const next = el.clientWidth < COMPACT_WIDTH;
if (next === compactRef.current) return;
compactRef.current = next;
setCompact(next);
setLibraryOpen(!next);
setInspectorOpen(!next);
};
measure();
observerRef.current = new ResizeObserver(measure);
observerRef.current.observe(el);
}, []);
// The app always registers annotation and the keyboard; anything a consumer
// opts into stacks on top. Memoized so the merged array keeps a stable
// identity across renders and doesn't rebuild the engine.
const allPlugins = React.useMemo(
() => (plugins ? [...ANNOTATOR_PLUGINS, ...plugins] : ANNOTATOR_PLUGINS),
[plugins],
);
return (
<PdfCommentUserProvider currentUser={CURRENT_USER}>
<PdfViewer
className={cn("h-full", className)}
plugins={allPlugins}
{...props}
>
{/*
`@container/toolbar` scopes the container queries below to the toolbar's
own width, so the controls shed to fit the space the viewer is given
rather than the viewport. Undo/redo sits at the front and never sheds —
on a surface whose whole purpose is editing, it's as essential as zoom.
The tools aren't here at all; they're in the rail below, which is what
buys this toolbar the room.
*/}
<PdfToolbar className="@container/toolbar">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Toggle annotations panel"
aria-pressed={libraryOpen}
onClick={() => setLibraryOpen((open) => !open)}
>
<PanelLeftIcon />
</Button>
}
/>
<TooltipContent>Toggle annotations panel</TooltipContent>
</Tooltip>
<PdfToolbarSeparator />
<PdfToolbarGroup>
<PdfUndoRedoButtons />
</PdfToolbarGroup>
<PdfToolbarSeparator />
{/*
Only the exact-value controls (the zoom-level select and the
page-number input) shed below `@lg/toolbar`; the +/- and prev/next
steppers stay at every width. The shedding lives here, not in the
primitives — the primitives stay pure and each carries a `data-slot`
this container query targets.
*/}
<PdfZoomControls className="**:data-[slot=pdf-zoom-controls-level]:@max-lg/toolbar:hidden" />
<PdfToolbarSeparator />
<PdfPageNavigation className="**:data-[slot=pdf-page-navigation-pages]:@max-lg/toolbar:hidden" />
<PdfSearchPopover />
<div className="ml-auto flex items-center gap-1">
<div className="hidden items-center gap-1 @2xl/toolbar:flex">
<PdfToolbarGroup>
<PdfOpenFileButton closeExisting />
<PdfExportButton />
</PdfToolbarGroup>
<PdfToolbarSeparator />
</div>
{/* Nothing that shed above is lost — the "⋮" menu still carries open,
print, rotate, spread, and document properties. */}
<PdfMoreActionsMenu closeExisting />
{/* An editor with a tool key per tool needs somewhere to read them.
Mounted here so `?` has a cheatsheet to open, at any width. */}
<PdfShortcutsDialog />
<PdfToolbarSeparator />
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Toggle inspector"
aria-pressed={inspectorOpen}
onClick={() => setInspectorOpen((open) => !open)}
>
<PanelRightIcon />
</Button>
}
/>
<TooltipContent>Toggle inspector</TooltipContent>
</Tooltip>
</div>
</PdfToolbar>
<div ref={bodyRef} className="relative flex min-h-0 flex-1">
{/*
The content panel — and the document render/scroll state inside it —
keeps a stable `key` so it's never torn down when a panel comes and
goes. Both panels mount and unmount rather than collapsing to zero
width, which is what lets the group hand their space straight back to
the canvas.
*/}
<ResizablePanelGroup className="min-w-0 flex-1">
{!compact && libraryOpen ? (
<ResizablePanel
key="library"
defaultSize={264}
minSize={208}
maxSize={420}
groupResizeBehavior="preserve-pixel-size"
className="bg-background flex flex-col"
>
<PdfAnnotatorAppLibraryPanel
tab={libraryTab}
onTabChange={setLibraryTab}
/>
</ResizablePanel>
) : null}
{!compact && libraryOpen ? (
<ResizableHandle key="library-handle" withHandle />
) : null}
{/* `relative` is what the floating pill pins itself to, so it tracks
the canvas rather than the whole body — it stays centred on the
page as the panels beside it open and close. */}
<ResizablePanel key="content" className="relative flex">
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<>
{/* Clicking with the comment tool opens a composer here
instead of writing an empty pin into the document — see
`<PdfCommentDraftLayer />`. Mounting it is what turns the
plugin's click-to-create off, so it goes wherever
`<PdfCommentPin />` does. */}
<PdfCommentDraftLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
customAnnotationRenderer={({
annotation,
children,
isSelected,
onSelect,
}) =>
// Comment pins render as avatar markers; every other
// annotation keeps its default rendering. Replies are Text
// annotations too, and they live in the thread rather than
// on the page, so they're matched out here.
isPdfCommentPin(annotation) ? (
<PdfCommentPin
annotation={annotation}
isSelected={isSelected}
onSelect={onSelect}
/>
) : (
children
)
}
selectionMenu={(props) =>
// Comment pins get the seamless on-page composer; every
// other annotation keeps the generic actions menu, with a
// button that opens its thread.
isPdfCommentPin(props.context.annotation.object) ? (
<PdfCommentPopover
documentId={documentId}
{...props}
/>
) : (
<PdfAnnotationSelectionMenu
documentId={documentId}
{...props}
>
<PdfCommentThreadButton
documentId={documentId}
annotation={props.context.annotation.object}
/>
</PdfAnnotationSelectionMenu>
)
}
/>
</>
)}
/>
{/*
One mutually exclusive set, in the order design tools put them:
the two interaction modes, then the drawing tools. They span two
plugins — pointer and pan come from the interaction manager,
the rest from the annotation plugin — but the interaction manager
holds a single active mode per document, so arming any one of
them disarms the others without anything here coordinating it.
`variant="default"` drops the segmented borders and `spacing={1}`
opens the row into separate pills, which is the shape a floating
toolbar wants; the docked `<PdfToolbar>` keeps the segmented
defaults.
*/}
<PdfFloatingToolbar>
<PdfPointerToggle size="lg" />
<PdfPanToggle size="lg" />
<PdfFloatingToolbarSeparator />
<PdfAnnotationToolbar
variant="default"
size="lg"
spacing={1}
tools={[
"square",
"circle",
"stamp",
"freeText",
"highlight",
"ink",
"textComment",
]}
/>
</PdfFloatingToolbar>
</ResizablePanel>
{!compact && inspectorOpen ? (
<ResizableHandle key="inspector-handle" withHandle />
) : null}
{!compact && inspectorOpen ? (
<ResizablePanel
key="inspector"
defaultSize={288}
minSize={240}
maxSize={420}
groupResizeBehavior="preserve-pixel-size"
className="bg-background flex flex-col"
>
<PdfAnnotatorAppInspectorPanel
tab={inspectorTab}
onTabChange={setInspectorTab}
/>
</ResizablePanel>
) : null}
</ResizablePanelGroup>
{/*
Compact: rather than share the row and crush the canvas, each panel
floats over it from its own edge, with a scrim that closes it on tap.
The scrim covers the tool pill too — while a panel is open it's the
panel you're working in, and tapping anywhere outside it should close
it rather than arm a tool you can't see the effect of.
*/}
{compact && libraryOpen ? (
<>
<button
type="button"
aria-label="Close annotations panel"
onClick={() => setLibraryOpen(false)}
className="absolute inset-0 z-20 bg-black/10 supports-backdrop-filter:backdrop-blur-xs"
/>
<aside className="bg-background absolute inset-y-0 left-0 z-30 flex w-64 flex-col border-r shadow-lg">
<PdfAnnotatorAppLibraryPanel
tab={libraryTab}
onTabChange={setLibraryTab}
/>
</aside>
</>
) : null}
{compact && inspectorOpen ? (
<>
<button
type="button"
aria-label="Close inspector"
onClick={() => setInspectorOpen(false)}
className="absolute inset-0 z-20 bg-black/10 supports-backdrop-filter:backdrop-blur-xs"
/>
<aside className="bg-background absolute inset-y-0 right-0 z-30 flex w-72 flex-col border-l shadow-lg">
<PdfAnnotatorAppInspectorPanel
tab={inspectorTab}
onTabChange={setInspectorTab}
/>
</aside>
</>
) : null}
</div>
</PdfViewer>
</PdfCommentUserProvider>
);
}
/**
* The shared shape of both panels: a tab strip that doubles as the header,
* over whichever panel is selected. Base UI unmounts the inactive panels, so
* only the visible one is subscribed to plugin state.
*/
function PdfAnnotatorAppPanel({
value,
onValueChange,
triggers,
children,
...props
}: Omit<React.ComponentProps<typeof Tabs>, "onValueChange"> & {
onValueChange: (value: string) => void;
triggers: { id: string; label: string }[];
}) {
return (
<Tabs
value={value}
onValueChange={(next) => onValueChange(String(next))}
className="min-h-0 flex-1 gap-0"
{...props}
>
{/* The strip sits in its own bordered row rather than being one: a
segmented control needs its own background to read as a group, so the
panel's rule and padding belong to the row around it.
The list keeps its default `w-fit` — stretching a segmented control
edge to edge dissolves the group, because the track then reads as the
row's background rather than as a pill around the tabs. */}
<div className="flex h-10 shrink-0 items-center border-b px-2">
<TabsList className="max-w-full">
{triggers.map((trigger) => (
// The strip *is* this panel's title bar, so it's sized like the
// rest of the panel's chrome rather than like body text — a tab
// label reading larger than the headings underneath it would put
// the loudest type on the least important line.
<TabsTrigger
key={trigger.id}
value={trigger.id}
className="text-xs"
>
{trigger.label}
</TabsTrigger>
))}
</TabsList>
</div>
{children}
</Tabs>
);
}
/**
* The left panel — what's in the document. Annotations leads because that's
* what this app is for; pages and the outline are the other two ways of
* finding your place.
*/
function PdfAnnotatorAppLibraryPanel({
tab,
onTabChange,
}: {
tab: LibraryTabId;
onTabChange: (tab: LibraryTabId) => void;
}) {
return (
<PdfAnnotatorAppPanel
value={tab}
onValueChange={(value) => onTabChange(value as LibraryTabId)}
triggers={[
{ id: "annotations", label: "Annotations" },
{ id: "pages", label: "Pages" },
{ id: "outline", label: "Outline" },
]}
>
<TabsContent value="annotations" className="min-h-0">
{/* The tab strip already names the panel, so the sidebar drops its own
title bar rather than stacking a second one beneath it. */}
<PdfAnnotationSidebar heading={null} className="h-full" />
</TabsContent>
<TabsContent value="pages" className="min-h-0">
<PdfThumbnailSidebar className="h-full" />
</TabsContent>
<TabsContent value="outline" className="min-h-0">
<PdfBookmarkSidebar className="h-full" />
</TabsContent>
</PdfAnnotatorAppPanel>
);
}
/**
* The right panel — what the selection looks like. The inspector styles
* whatever is selected, or the armed tool while nothing is; comments are the
* threads hanging off the pins.
*/
function PdfAnnotatorAppInspectorPanel({
tab,
onTabChange,
}: {
tab: InspectorTabId;
onTabChange: (tab: InspectorTabId) => void;
}) {
return (
<PdfAnnotatorAppPanel
value={tab}
onValueChange={(value) => onTabChange(value as InspectorTabId)}
triggers={[
{ id: "properties", label: "Properties" },
{ id: "comments", label: "Comments" },
]}
>
<TabsContent value="properties" className="min-h-0">
{/* The inspector keeps its header: it names the current target, which
the tab label can't. */}
<PdfAnnotationInspector className="h-full" />
</TabsContent>
<TabsContent value="comments" className="min-h-0">
<PdfCommentSidebar heading={null} className="h-full" />
</TabsContent>
</PdfAnnotatorAppPanel>
);
}
The same components with the priorities reversed — marking up the document is the job, so the surface that supports it is on screen from the first frame. Three regions after Figma: a tool pill floating over the canvas that never sheds, an annotations index for what's in the document, and a style inspector for what the selection looks like.