0
Files
pdf-annotator-app.tsx
"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.