0
Files
pdf-viewer-app.tsx
"use client";

import { createPluginRegistration } from "@embedpdf/core";
import type { PluginBatchRegistrations } from "@embedpdf/core/react";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
import {
  LayoutGridIcon,
  ListTreeIcon,
  MessagesSquareIcon,
  PanelLeftIcon,
  PaperclipIcon,
  ShapesIcon,
  SlidersHorizontalIcon,
} from "lucide-react";
import * as React from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";

import { Button } from "@/components/ui/button";
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Toggle } from "@/components/ui/toggle";
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 { PdfAttachmentList } from "@/components/pdf-attachment-list";
import { PdfBookmarkSidebar } from "@/components/pdf-bookmark-sidebar";
import {
  PdfCommentPin,
  PdfCommentPopover,
  PdfCommentThreadButton,
  type PdfCommentUser,
  PdfCommentUserProvider,
  isPdfCommentPin,
} from "@/components/pdf-comment";
import { PdfCommentButton } from "@/components/pdf-comment-button";
import { PdfCommentDraftLayer } from "@/components/pdf-comment-draft";
import { PdfCommentSidebar } from "@/components/pdf-comment-sidebar";
import {
  PdfDocumentPropertiesDialog,
  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,
} 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";

interface Panel<Id extends string> {
  id: Id;
  label: string;
  icon: React.ComponentType<{ className?: string }>;
}

type PanelId = "thumbnails" | "outline" | "annotations" | "attachments";

/**
 * The left rail's four faces — ways of finding your place in the document.
 * Annotations sits with them rather than on the right because it answers *what
 * is here*, the same question thumbnails and the outline answer, while the
 * right-hand panel answers *what does the selected thing look like*. That's
 * Figma's layers-left, properties-right split, and it keeps the right panel to
 * PDFSlick's `Annotate | Comment` pair.
 */
const PANELS: Panel<PanelId>[] = [
  { id: "thumbnails", label: "Thumbnails", icon: LayoutGridIcon },
  { id: "outline", label: "Outline", icon: ListTreeIcon },
  { id: "annotations", label: "Annotations", icon: ShapesIcon },
  { id: "attachments", label: "Attachments", icon: PaperclipIcon },
];

type InspectorPanelId = "annotate" | "comments";

/**
 * The right-hand panel's two faces, following PDFSlick's `Annotate | Comment`
 * split: styling whatever is selected, and the comment threads. Only one shows
 * at a time, and each component brings its own header — so the panel itself
 * needs no chrome, and the toolbar toggles double as the tab strip.
 */
const INSPECTOR_PANELS: Panel<InspectorPanelId>[] = [
  { id: "annotate", label: "Annotate", icon: SlidersHorizontalIcon },
  { id: "comments", label: "Comments", icon: MessagesSquareIcon },
];

/**
 * 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 highlight 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 VIEWER_PLUGINS: PluginBatchRegistrations = [
  // Covers what the plugin creates on its own — a highlight dragged over text,
  // which never passes through the comment components.
  createPluginRegistration(AnnotationPluginPackage, {
    annotationAuthor: CURRENT_USER.name,
  }),
  // Both presets. This is a reader first, so the chrome chords carry it — but it
  // annotates too, and the editing keys stay disabled until something is
  // selected, which is what keeps Backspace and the arrows reading-safe.
  createKeyboardShortcutsRegistration(),
];

/**
 * A complete single-document PDF reader assembled entirely from pdfcn's
 * registry components, laid out to match PDFSlick's reference app:
 * `<PdfViewer>` bootstraps the engine, `<PdfToolbar>` holds the controls —
 * zoom and page navigation on the left, search next to them, annotation
 * tools and output actions on the right, with everything else (rotate,
 * spread, pan, fullscreen, print, and more) tucked behind
 * `<PdfMoreActionsMenu>`'s "⋮" — `<PdfViewerContent>` is the page canvas, a
 * left rail switches the sidebar between thumbnails, outline, and attachments,
 * and a right panel switches between the style inspector and comments.
 * Annotation is registered here rather than by `<PdfViewer>`, since it's a
 * heavy-tier plugin that's off by default.
 *
 * Both `<PdfOpenFileButton>` and `<PdfMoreActionsMenu>` are given
 * `closeExisting`, so opening a new file — from either control — always
 * replaces the current document rather than accumulating hidden ones, since
 * this app's whole premise is a single document at a time even though the
 * underlying capability is inherently multi-document.
 *
 * The root fills its parent, so give it a sized container (a viewport-height
 * flex cell, or an explicit height).
 */
// Below this viewer width the sidebar would leave too little room for the page
// canvas beside it, so it stops sharing the row and overlays instead — and the
// toolbar sheds its secondary clusters (see the `@container/toolbar` queries in
// the render). Roughly a phone in portrait; tablets and up keep the full layout.
const COMPACT_WIDTH = 640;

export function PdfViewerApp({
  className,
  plugins,
  ...props
}: Omit<React.ComponentProps<typeof PdfViewer>, "children">) {
  const [activePanel, setActivePanel] = React.useState<PanelId>("thumbnails");
  const [panelOpen, setPanelOpen] = React.useState(true);
  // `null` closes the right panel outright, so the two toolbar toggles are both
  // the tab strip and the open/close control — same select-or-collapse gesture
  // the left rail uses.
  const [inspectorPanel, setInspectorPanel] =
    React.useState<InspectorPanelId | null>(null);
  const sidebarPanelRef = React.useRef<PanelImperativeHandle>(null);

  // 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 also resets the sidebar to its default for that size —
  // open when there's room, folded away when compact so the document keeps the
  // width (the rail brings it back, overlaying rather than squeezing). Within a
  // size the user's own toggle stands.
  //
  // 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);
      setPanelOpen(!next);
    };
    measure();
    observerRef.current = new ResizeObserver(measure);
    observerRef.current.observe(el);
  }, []);

  // Drive the resizable panel imperatively so the rail toggles and the drag
  // handle stay in sync: `panelOpen` is the source of truth, and the panel's
  // `onResize` (below) folds a drag-to-collapse back into it. Guarding on
  // `isCollapsed()` keeps the two from ping-ponging.
  //
  // Skipped both while compact (the panel is unmounted; the overlay shows the
  // open state) and on the tick we leave compact — the panel remounts fresh
  // with its `defaultSize`, and touching its imperative handle before the group
  // has re-registered its constraints throws "Panel constraints not found".
  const wasCompactRef = React.useRef(compact);
  React.useEffect(() => {
    const leftCompact = wasCompactRef.current !== compact;
    wasCompactRef.current = compact;

    const panel = sidebarPanelRef.current;
    if (!panel || compact || leftCompact) return;

    if (panelOpen && panel.isCollapsed()) {
      panel.expand();
    } else if (!panelOpen && !panel.isCollapsed()) {
      panel.collapse();
    }
  }, [panelOpen, compact]);

  // 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 ? [...VIEWER_PLUGINS, ...plugins] : VIEWER_PLUGINS),
    [plugins],
  );

  // Re-selecting the open panel collapses the sidebar; selecting a different
  // one switches to it and (re)opens.
  const selectPanel = (panel: PanelId) => {
    if (panel === activePanel) {
      setPanelOpen((open) => !open);
      return;
    }

    setActivePanel(panel);
    setPanelOpen(true);
  };

  // Same gesture on the right: a different tab switches to it, the open one
  // closes the panel.
  const selectInspectorPanel = (panel: InspectorPanelId) =>
    setInspectorPanel((current) => (current === panel ? null : panel));

  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. Priority runs from the essentials — sidebar
        toggle, zoom, and the "⋮" menu that always keeps every hidden action
        reachable — out to the wider clusters, which drop first: page navigation,
        then the file actions, then the annotation surface. Nothing that shed
        below is truly lost, since the "⋮" menu still carries open, print,
        rotate, spread, and document properties.
      */}
        <PdfToolbar className="@container/toolbar">
          <Tooltip>
            <TooltipTrigger
              render={
                <Button
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Toggle sidebar"
                  aria-pressed={panelOpen}
                  onClick={() => setPanelOpen((open) => !open)}
                >
                  <PanelLeftIcon />
                </Button>
              }
            />
            <TooltipContent>Toggle sidebar</TooltipContent>
          </Tooltip>
          <PdfToolbarSeparator />
          {/*
          Following PDFSlick: the zoom +/- and page prev/next steppers stay at
          every width; only the exact-value controls (the zoom-level select and
          the page-number input) shed below `@lg/toolbar` to reclaim space. 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 @4xl/toolbar:flex">
              <PdfAnnotationToolbar />
              <PdfCommentButton />
              <PdfToolbarSeparator />
              <PdfToolbarGroup>
                <PdfUndoRedoButtons />
              </PdfToolbarGroup>
              <PdfToolbarSeparator />
            </div>
            <div className="hidden items-center gap-1 @2xl/toolbar:flex">
              <PdfToolbarGroup>
                <PdfOpenFileButton closeExisting />
                <PdfExportButton />
                <PdfDocumentPropertiesDialog />
              </PdfToolbarGroup>
              <PdfToolbarSeparator />
            </div>
            <PdfMoreActionsMenu closeExisting />
            {/* Gives `?` a cheatsheet to open, and the keyboard somewhere to be
              discovered from. Mounted outside the shedding groups so it's there
              at every width. */}
            <PdfShortcutsDialog />
            <PdfToolbarSeparator />
            {INSPECTOR_PANELS.map((panel) => (
              <Tooltip key={panel.id}>
                <TooltipTrigger
                  render={
                    <Button
                      variant="ghost"
                      size="icon-sm"
                      aria-label={panel.label}
                      aria-pressed={inspectorPanel === panel.id}
                      onClick={() => selectInspectorPanel(panel.id)}
                    >
                      <panel.icon />
                    </Button>
                  }
                />
                <TooltipContent>{panel.label}</TooltipContent>
              </Tooltip>
            ))}
          </div>
        </PdfToolbar>

        <div ref={bodyRef} className="relative flex min-h-0 flex-1">
          <PdfViewerAppSidebarRail
            activePanel={activePanel}
            open={panelOpen}
            onSelect={selectPanel}
          />
          {/*
          The content panel — and the document render/scroll state inside it —
          keeps a stable `key` so it's never torn down when the sidebar comes
          and goes: neither a toggle (which collapses the sibling panel to zero
          width) nor the switch to compact (which drops the sibling panel and
          handle entirely, letting the content fill the row) reconciles onto the
          content panel. In compact the sidebar is shown by the overlay below
          instead, so the page canvas is never squeezed.
        */}
          <ResizablePanelGroup className="min-w-0 flex-1">
            {!compact ? (
              <ResizablePanel
                key="sidebar"
                panelRef={sidebarPanelRef}
                collapsible
                collapsedSize={0}
                defaultSize={256}
                minSize={200}
                maxSize={480}
                groupResizeBehavior="preserve-pixel-size"
                onResize={(size) => setPanelOpen(size.inPixels > 0)}
                className="bg-background flex flex-col"
              >
                <PdfViewerAppSidebarPanel activePanel={activePanel} />
              </ResizablePanel>
            ) : null}
            {!compact ? <ResizableHandle key="handle" withHandle /> : null}
            <ResizablePanel key="content" className="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>
                        )
                      }
                    />
                  </>
                )}
              />
            </ResizablePanel>
            {!compact && inspectorPanel ? (
              <ResizableHandle key="inspector-handle" withHandle />
            ) : null}
            {!compact && inspectorPanel ? (
              <ResizablePanel
                key="inspector"
                defaultSize={288}
                minSize={240}
                maxSize={480}
                groupResizeBehavior="preserve-pixel-size"
                className="bg-background flex flex-col"
              >
                <PdfViewerAppInspectorPanel panel={inspectorPanel} />
              </ResizablePanel>
            ) : null}
          </ResizablePanelGroup>

          {/*
          Compact overlay: rather than share the row and crush the canvas, the
          sidebar floats over it next to the rail, with a scrim that closes it
          on tap — the small-screen counterpart to the resizable panel above.
        */}
          {compact && panelOpen ? (
            <>
              <button
                type="button"
                aria-label="Close sidebar"
                onClick={() => setPanelOpen(false)}
                className="absolute inset-y-0 right-0 left-12 z-20 bg-black/10 supports-backdrop-filter:backdrop-blur-xs"
              />
              <aside className="bg-background absolute inset-y-0 left-12 z-30 flex w-64 flex-col border-r shadow-lg">
                <PdfViewerAppSidebarPanel activePanel={activePanel} />
              </aside>
            </>
          ) : null}

          {/* The right panel gets the same compact treatment, floating in from
            the right so the canvas underneath stays full width. */}
          {compact && inspectorPanel ? (
            <>
              <button
                type="button"
                aria-label="Close panel"
                onClick={() => setInspectorPanel(null)}
                className="absolute inset-y-0 right-72 left-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">
                <PdfViewerAppInspectorPanel panel={inspectorPanel} />
              </aside>
            </>
          ) : null}
        </div>
      </PdfViewer>
    </PdfCommentUserProvider>
  );
}

interface PdfViewerAppSidebarRailProps {
  activePanel: PanelId;
  open: boolean;
  onSelect: (panel: PanelId) => void;
}

/**
 * The fixed icon rail that switches the sidebar panel between thumbnails,
 * outline, and attachments. It lives outside the resizable group and keeps a
 * constant width, so resizing and collapsing only affect the panel beside it.
 */
function PdfViewerAppSidebarRail({
  activePanel,
  open,
  onSelect,
}: PdfViewerAppSidebarRailProps) {
  return (
    <div
      data-slot="pdf-viewer-app-sidebar-rail"
      className="bg-background flex w-12 shrink-0 flex-col items-center gap-1 border-r py-2"
    >
      {PANELS.map((panel) => (
        <Tooltip key={panel.id}>
          <TooltipTrigger
            render={
              <Toggle
                aria-label={panel.label}
                pressed={open && activePanel === panel.id}
                onPressedChange={() => onSelect(panel.id)}
                className="size-9 shrink-0 p-0"
              >
                <panel.icon />
              </Toggle>
            }
          />
          <TooltipContent side="right">{panel.label}</TooltipContent>
        </Tooltip>
      ))}
    </div>
  );
}

/**
 * The body of the resizable sidebar panel: a header naming the active panel
 * and the matching content. Rendered inside a `<ResizablePanel>`, so it fills
 * the panel and clips cleanly to zero width when the panel is collapsed.
 */
function PdfViewerAppSidebarPanel({ activePanel }: { activePanel: PanelId }) {
  const active = PANELS.find((panel) => panel.id === activePanel);

  return (
    <div
      data-slot="pdf-viewer-app-sidebar-panel"
      className="flex min-h-0 flex-1 flex-col"
    >
      {/* The annotations sidebar ships its own header — with a running count
          the shell's can't show — so the shell steps aside rather than
          stacking two bars, the same deal the inspector panel gets. */}
      {activePanel === "annotations" ? null : (
        <div className="flex h-10 shrink-0 items-center border-b px-4">
          <span className="text-xs font-medium">{active?.label}</span>
        </div>
      )}
      <div className="min-h-0 flex-1">
        {activePanel === "thumbnails" ? (
          <PdfThumbnailSidebar className="h-full" />
        ) : null}
        {activePanel === "outline" ? (
          <PdfBookmarkSidebar className="h-full" />
        ) : null}
        {activePanel === "annotations" ? (
          <PdfAnnotationSidebar className="h-full" />
        ) : null}
        {activePanel === "attachments" ? (
          <PdfAttachmentList className="h-full" />
        ) : null}
      </div>
    </div>
  );
}

/**
 * The right-hand panel's body. Unlike the sidebar it adds no header of its own
 * — `<PdfAnnotationInspector>` and `<PdfCommentSidebar>` each ship one, and a
 * second would just repeat the title the toolbar toggle already pressed.
 */
function PdfViewerAppInspectorPanel({ panel }: { panel: InspectorPanelId }) {
  return panel === "annotate" ? (
    <PdfAnnotationInspector className="h-full" />
  ) : (
    <PdfCommentSidebar className="h-full" />
  );
}

The layout — a toolbar over a collapsible left rail that switches between thumbnails, an outline, annotations, and attachments, beside the page canvas, with a style inspector and comment threads on the right — follows PDFSlick's reference app. It reads first and annotates second: the tools sit in the toolbar, and the panels open on demand.