0

Building blocks for PDF viewers

Whole readers assembled from pdfcn's components — a toolbar, sidebars, search, and annotation on embedpdf's engine. Preview one, then copy the source and make it yours.

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" />
  );
}
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>
  );
}
Files
pdf-workspace-app.tsx
"use client";

import { useDocumentState } from "@embedpdf/core/react";
import { useAllViews } from "@embedpdf/plugin-view-manager/react";
import {
  LayoutGridIcon,
  ListTreeIcon,
  PaperclipIcon,
  PinIcon,
} from "lucide-react";
import * as React from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";

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 { PdfAttachmentList } from "@/components/pdf-attachment-list";
import { PdfBookmarkSidebar } from "@/components/pdf-bookmark-sidebar";
import { PdfDocumentTabs } from "@/components/pdf-document-tabs";
import { PdfMoreActionsMenu } from "@/components/pdf-more-actions-menu";
import { PdfPageNavigation } from "@/components/pdf-page-navigation";
import { PdfSearchPopover } from "@/components/pdf-search-panel";
import { PdfThumbnailSidebar } from "@/components/pdf-thumbnail-sidebar";
import {
  PdfToolbar,
  PdfToolbarSeparator,
} from "@/components/pdf-toolbar";
import {
  PdfClosePaneButton,
  PdfMoveToNewPaneButton,
  PdfView,
} from "@/components/pdf-view";
import { PdfViewer, usePdfView } from "@/components/pdf-viewer";
import { PdfViewerContent } from "@/components/pdf-viewer-content";
import { PdfZoomControls } from "@/components/pdf-zoom-controls";

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

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

const PANELS: Panel[] = [
  { id: "thumbnails", label: "Thumbnails", icon: LayoutGridIcon },
  { id: "outline", label: "Outline", icon: ListTreeIcon },
  { id: "attachments", label: "Attachments", icon: PaperclipIcon },
];

/**
 * A multi-document PDF workspace assembled entirely from pdfcn's registry
 * components. Where `<PdfViewerApp>` reads one document at a time, this one
 * leans into embedpdf's view-manager: every document opens as a tab, and any
 * view can be split into resizable panes for side-by-side reading.
 *
 * The layout has three parts:
 * - A fixed icon rail switches the shared sidebar between thumbnails, outline,
 *   and attachments. It sits outside every `<PdfView>`, so it reads the
 *   *focused* pane's document — click into a pane and the sidebar follows.
 * - A resizable sidebar panel holds the active rail's content, which can be
 *   pinned to one pane instead of following focus.
 * - `<PdfWorkspacePanes>` fills the rest, one `<PdfView>` per view. Each pane is
 *   a self-contained viewer — its own `<PdfDocumentTabs>`, toolbar, and
 *   `<PdfViewerContent>` — scoped to that view, so switching a tab or paging
 *   through one pane never disturbs the other.
 *
 * Opening a file (the tab strip's `+`, or the "⋮" menu) adds a tab to the
 * focused pane rather than replacing the current document — accumulating
 * documents is the whole point here, the opposite of `<PdfViewerApp>`'s
 * `closeExisting`.
 *
 * The root fills its parent, so give it a sized container (a viewport-height
 * flex cell, or an explicit height).
 */
// Below this viewer width a second pane and a docked sidebar leave too little
// room to read, so the sidebar stops sharing the row and overlays instead, and
// each pane's toolbar sheds its secondary controls (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 PdfWorkspaceApp({
  className,
  ...props
}: Omit<React.ComponentProps<typeof PdfViewer>, "children">) {
  const [activePanel, setActivePanel] = React.useState<PanelId>("thumbnails");
  const [panelOpen, setPanelOpen] = React.useState(true);
  const sidebarPanelRef = React.useRef<PanelImperativeHandle>(null);

  // Which pane the sidebar is pinned to, or null while it follows focus. Held
  // here rather than in the panel so the docked panel and the compact overlay
  // — two instances, only ever one on screen — agree on it across a resize.
  const [pinnedViewId, setPinnedViewId] = React.useState<string | null>(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 resets the sidebar to its default for
  // that size — open when there's room, folded away when compact so the panes
  // keep 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]);

  // Re-selecting the open panel collapses the sidebar; selecting a different
  // one switches to it and (re)opens. The rail is the sidebar's only toggle,
  // since each pane owns its toolbar and there's no shared top bar.
  const selectPanel = (panel: PanelId) => {
    if (panel === activePanel) {
      setPanelOpen((open) => !open);
      return;
    }

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

  return (
    <PdfViewer className={cn("h-full", className)} {...props}>
      <div ref={bodyRef} className="relative flex min-h-0 flex-1">
        <PdfWorkspaceSidebarRail
          activePanel={activePanel}
          open={panelOpen}
          onSelect={selectPanel}
        />
        {/*
          The split view — and the per-pane document/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 panes fill the row) reconciles onto the
          content panel. In compact the sidebar is shown by the overlay below
          instead, so the panes are 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"
            >
              <PdfWorkspaceSidebarPanel
                activePanel={activePanel}
                pinnedViewId={pinnedViewId}
                onPinnedViewIdChange={setPinnedViewId}
              />
            </ResizablePanel>
          ) : null}
          {!compact ? <ResizableHandle key="handle" withHandle /> : null}
          <ResizablePanel key="content" className="flex min-w-0">
            <PdfWorkspacePanes />
          </ResizablePanel>
        </ResizablePanelGroup>

        {/*
          Compact overlay: rather than share the row and crush the panes, the
          sidebar floats over them 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">
              <PdfWorkspaceSidebarPanel
                activePanel={activePanel}
                pinnedViewId={pinnedViewId}
                onPinnedViewIdChange={setPinnedViewId}
              />
            </aside>
          </>
        ) : null}
      </div>
    </PdfViewer>
  );
}

/**
 * One resizable pane per view. pdfcn deliberately ships no layout component for
 * this — `useAllViews()` gives you the views and `<PdfView>` scopes a subtree to
 * one, and how they're arranged is the block's business. A `<ResizablePanelGroup>`
 * is the arrangement that suits a desktop workspace; a CSS grid, a tab strip, or
 * a portal into a second browser window would each be a one-line change here.
 *
 * Lives in its own component rather than inline in `<PdfWorkspaceApp>` because
 * `useAllViews()` needs the plugin registry, and `<PdfViewer>` only mounts its
 * children once the engine is ready.
 */
function PdfWorkspacePanes() {
  const views = useAllViews();

  return (
    <ResizablePanelGroup className="min-w-0 flex-1">
      {views.map((view, index) => (
        // The view id doubles as the panel id, which is how a persisted layout
        // finds its way back to the right pane. Left to its `useId()` fallback,
        // closing the left pane would hand its saved size to whichever pane
        // took its place.
        <React.Fragment key={view.id}>
          {index > 0 ? <ResizableHandle withHandle /> : null}
          <ResizablePanel id={view.id} className="min-w-0">
            <PdfView
              viewId={view.id}
              // Ring the focused pane once there's more than one, so the shared
              // sidebar visibly belongs to a pane. Inset, because an outer ring
              // would sit under the resize handle and be clipped by the panel.
              className={cn(
                views.length > 1 &&
                  "ring-ring/50 transition-shadow ring-inset data-[focused=true]:ring-[3px]",
              )}
            >
              {/*
                The pane's own actions ride in the tab strip, not the toolbar
                below it — they act on the group of tabs (move one out, close
                the lot), while the toolbar acts on the document those tabs
                select. VS Code splits editor-group actions from editor content
                the same way, and it leaves the toolbar the whole row for the
                controls that shed under a container query.
              */}
              <PdfDocumentTabs>
                <PdfMoveToNewPaneButton />
                <PdfClosePaneButton />
                <PdfToolbarSeparator />
                <PdfMoreActionsMenu />
              </PdfDocumentTabs>
              {/*
                `@container/toolbar` scopes the container queries to each pane's
                own toolbar width, so one pane sheds controls independently of
                its sibling. The exact-value controls (the zoom-level select,
                the page-number input) drop first below `@lg/toolbar`; the
                steppers stay at every width.
              */}
              <PdfToolbar className="@container/toolbar">
                <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 />
              </PdfToolbar>
              <PdfViewerContent />
            </PdfView>
          </ResizablePanel>
        </React.Fragment>
      ))}
    </ResizablePanelGroup>
  );
}

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

/**
 * The fixed icon rail that switches the shared sidebar between thumbnails,
 * outline, and attachments. It lives outside the resizable group and every
 * `<PdfView>`, so it keeps a constant width and reads the focused pane's
 * document — resizing, collapsing, or focusing a pane only affects what it
 * shows, never its own footprint.
 */
function PdfWorkspaceSidebarRail({
  activePanel,
  open,
  onSelect,
}: PdfWorkspaceSidebarRailProps) {
  return (
    <div
      data-slot="pdf-workspace-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)}
                size="sm"
                className="size-7"
              >
                <panel.icon />
              </Toggle>
            }
          />
          <TooltipContent side="right">{panel.label}</TooltipContent>
        </Tooltip>
      ))}
    </div>
  );
}

interface PdfWorkspaceSidebarPanelProps {
  activePanel: PanelId;
  pinnedViewId: string | null;
  onPinnedViewIdChange: (viewId: string | null) => void;
}

/**
 * The shared sidebar panel. By default it renders outside every `<PdfView>` and
 * so reads the focused pane — the convention editors settled on (VS Code keeps
 * one side bar however many editor groups are open), and the reason the rail
 * needn't be duplicated per pane.
 *
 * Focus-following has a well-known cost, though: the panel's contents change
 * under you the moment you click the other pane. Editors answer that by letting
 * a view be pinned, and so does this — pinning wraps the body in a `<PdfView>`,
 * which is the entire implementation. Nothing inside learns about pinning; the
 * components keep reading the ambient view, and this only decides which view
 * that is.
 */
function PdfWorkspaceSidebarPanel({
  activePanel,
  pinnedViewId,
  onPinnedViewIdChange,
}: PdfWorkspaceSidebarPanelProps) {
  const views = useAllViews();

  // A pin names a pane, and panes close. Rather than track that with an effect,
  // read the pin as live only while its view is: a pin left over from a closed
  // pane simply reverts to following focus.
  const pinned = views.some((view) => view.id === pinnedViewId)
    ? pinnedViewId
    : null;

  const body = (
    <PdfWorkspaceSidebarPanelBody
      activePanel={activePanel}
      pinned={pinned !== null}
      // One pane is its own pin: there's no other pane for the sidebar to
      // follow, so the control has nothing to say until the view is split.
      showPin={views.length > 1}
      onPinnedViewIdChange={onPinnedViewIdChange}
    />
  );

  return pinned ? (
    <PdfView viewId={pinned} className="min-h-0 flex-1">
      {body}
    </PdfView>
  ) : (
    body
  );
}

interface PdfWorkspaceSidebarPanelBodyProps {
  activePanel: PanelId;
  pinned: boolean;
  showPin: boolean;
  onPinnedViewIdChange: (viewId: string | null) => void;
}

/**
 * A header naming the active panel and the matching content, both scoped to
 * whichever view encloses them — the pinned pane, or the focused one. Rendered
 * inside a `<ResizablePanel>`, so it fills the panel and clips cleanly to zero
 * width when the panel is collapsed.
 */
function PdfWorkspaceSidebarPanelBody({
  activePanel,
  pinned,
  showPin,
  onPinnedViewIdChange,
}: PdfWorkspaceSidebarPanelBodyProps) {
  const active = PANELS.find((panel) => panel.id === activePanel);

  // Name the document this panel reflects, and pin to the view it belongs to —
  // the same ambient read in both cases, so "pin" always means "keep showing
  // what I'm looking at now", whether that came from focus or an existing pin.
  const { viewId, documentId } = usePdfView();
  const documentName = useDocumentState(documentId)?.name;

  return (
    <div
      data-slot="pdf-workspace-sidebar-panel"
      className="flex min-h-0 flex-1 flex-col"
    >
      <div className="flex h-10 shrink-0 items-center gap-2 border-b pr-1.5 pl-3">
        <span className="shrink-0 text-sm font-medium">{active?.label}</span>
        {documentName ? (
          <span
            className="text-muted-foreground min-w-0 truncate text-xs"
            title={documentName}
          >
            {documentName}
          </span>
        ) : null}
        {showPin ? (
          <Tooltip>
            <TooltipTrigger
              render={
                <Toggle
                  aria-label={pinned ? "Unpin sidebar" : "Pin sidebar to pane"}
                  pressed={pinned}
                  onPressedChange={(next) =>
                    onPinnedViewIdChange(next ? viewId : null)
                  }
                  size="sm"
                  className="ml-auto size-7"
                >
                  <PinIcon />
                </Toggle>
              }
            />
            <TooltipContent>
              {pinned ? "Following this pane" : "Pin to this pane"}
            </TooltipContent>
          </Tooltip>
        ) : null}
      </div>
      <div className="min-h-0 flex-1">
        {activePanel === "thumbnails" ? (
          <PdfThumbnailSidebar className="h-full" />
        ) : null}
        {activePanel === "outline" ? (
          <PdfBookmarkSidebar className="h-full" />
        ) : null}
        {activePanel === "attachments" ? (
          <PdfAttachmentList className="h-full" />
        ) : null}
      </div>
    </div>
  );
}

Block compare-app has no preview route.

Files
pdf-library-app.tsx
"use client";

import { useDocumentState } from "@embedpdf/core/react";
import { LayoutGridIcon } from "lucide-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { PdfDocumentGrid } from "@/components/pdf-document-grid";
import { PdfMoreActionsMenu } from "@/components/pdf-more-actions-menu";
import { PdfPageNavigation } from "@/components/pdf-page-navigation";
import { PdfSearchPopover } from "@/components/pdf-search-panel";
import {
  PdfToolbar,
  PdfToolbarGroup,
  PdfToolbarSeparator,
} from "@/components/pdf-toolbar";
import { PdfViewer, usePdfView } from "@/components/pdf-viewer";
import { PdfViewerContent } from "@/components/pdf-viewer-content";
import { PdfZoomControls } from "@/components/pdf-zoom-controls";

/**
 * A document-library launcher assembled entirely from pdfcn's registry
 * components, after PDFSlick's MultipleDocuments example. It has two modes on
 * one engine:
 * - **Library** — `<PdfDocumentGrid>` fills the frame: every open document is a
 *   navigable card, plus a card that opens more. Picking a card routes it into
 *   the shared view and flips to the reader.
 * - **Reader** — a full-screen `<PdfViewerContent>` of the picked document with
 *   a toolbar. The toolbar's grid button returns to the library.
 *
 * Both modes read the *same* `<PdfViewer>`: the grid renders every document off
 * the thumbnail plugin without a viewer per file, and the reader shows the
 * focused view's active document — so switching modes never reloads anything.
 *
 * The root fills its parent, so give it a sized container (a viewport-height
 * flex cell, or an explicit height).
 */
export function PdfLibraryApp({
  className,
  ...props
}: Omit<React.ComponentProps<typeof PdfViewer>, "children">) {
  const [mode, setMode] = React.useState<"library" | "reader">("library");

  return (
    <PdfViewer className={cn("h-full", className)} {...props}>
      {mode === "library" ? (
        <PdfDocumentGrid
          className="min-h-0 flex-1"
          onSelectDocument={() => setMode("reader")}
        />
      ) : (
        <PdfLibraryReader onBackToLibrary={() => setMode("library")} />
      )}
    </PdfViewer>
  );
}

/**
 * The reader mode: a toolbar over the page canvas, scoped to the focused view's
 * active document (the card the user just picked). The leading grid button is
 * the way back to the library; the document's name sits beside it so it's clear
 * which one is open.
 */
function PdfLibraryReader({
  onBackToLibrary,
}: {
  onBackToLibrary: () => void;
}) {
  const { documentId } = usePdfView();
  const documentName = useDocumentState(documentId)?.name;

  return (
    <div className="flex min-h-0 flex-1 flex-col">
      {/*
        `@container/toolbar` scopes the width queries to the toolbar itself, so
        the exact-value controls (the zoom-level select, the page-number input)
        drop below `@lg/toolbar` while the steppers, search, and back button
        stay — the block reads the same embedded in a column as full-screen.
      */}
      <PdfToolbar className="@container/toolbar">
        <PdfToolbarGroup>
          <Tooltip>
            <TooltipTrigger
              render={
                <Button
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Back to library"
                  onClick={onBackToLibrary}
                >
                  <LayoutGridIcon />
                </Button>
              }
            />
            <TooltipContent>Back to library</TooltipContent>
          </Tooltip>
        </PdfToolbarGroup>
        {documentName ? (
          <span
            className="text-muted-foreground min-w-0 truncate text-sm"
            title={documentName}
          >
            {documentName}
          </span>
        ) : null}

        <div className="ml-auto flex items-center gap-1">
          <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 />
          <PdfToolbarSeparator />
          <PdfMoreActionsMenu />
        </div>
      </PdfToolbar>
      <PdfViewerContent />
    </div>
  );
}
Files
pdf-library-browser.tsx
"use client";

import { useDocumentState } from "@embedpdf/core/react";
import { ThumbImg } from "@embedpdf/plugin-thumbnail/react";
import * as React from "react";

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { PdfDocumentGrid } from "@/components/pdf-document-grid";
import { PdfDocumentInfo } from "@/components/pdf-document-info";
import { PdfViewer, usePdfView } from "@/components/pdf-viewer";

/**
 * A document library with an information sidebar, assembled from pdfcn's
 * registry components after PDFSlick's MultipleDocuments example. The grid is
 * the shelf — every open document a navigable card, plus a card that opens
 * more; the sidebar inspects whichever card you pick.
 *
 * Both halves read the *same* `<PdfViewer>` off one engine: `<PdfDocumentGrid>`
 * previews every document off the thumbnail plugin, and picking a card routes
 * it into the shared view — which is exactly what `<PdfDocumentInfo>` reads, so
 * the sidebar tracks the selection without any wiring of its own.
 *
 * The root fills its parent, so give it a sized container (a viewport-height
 * flex cell, or an explicit height).
 */
export function PdfLibraryBrowser({
  className,
  ...props
}: Omit<React.ComponentProps<typeof PdfViewer>, "children">) {
  return (
    <PdfViewer className={cn("h-full", className)} {...props}>
      <ResizablePanelGroup>
        <ResizablePanel className="min-w-0">
          <PdfDocumentGrid className="h-full" />
        </ResizablePanel>
        <ResizableHandle withHandle />
        <ResizablePanel
          defaultSize={320}
          minSize={260}
          maxSize={480}
          className="min-w-0"
        >
          <PdfLibraryInfoSidebar />
        </ResizablePanel>
      </ResizablePanelGroup>
    </PdfViewer>
  );
}

/**
 * The inspector: a preview of the selected document over its PDF properties.
 * Everything here reads the focused view's active document (the picked card),
 * so it re-populates itself whenever the selection in the grid changes.
 */
function PdfLibraryInfoSidebar() {
  const { documentId } = usePdfView();
  const documentState = useDocumentState(documentId);
  const loaded = documentState?.status === "loaded";

  return (
    <ScrollArea className="bg-background h-full">
      <div className="flex flex-col gap-4 p-4">
        <h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
          Details
        </h2>

        <PdfLibraryInfoPreview documentId={documentId} loaded={loaded} />

        {/* The file name isn't captioned under the preview: `<PdfDocumentInfo>`
            already lists it under **File**, and it says the same thing about an
            empty selection. Two lines for one fact is what a details panel is
            supposed to save the reader from. */}
        <PdfDocumentInfo documentId={documentId} />
      </div>
    </ScrollArea>
  );
}

// US Letter (8.5×11) as a width/height ratio — the frame before the document
// reports its own first-page size.
const FALLBACK_ASPECT_RATIO = 8.5 / 11;

/**
 * A single-page preview of the selected document, sized to its own first page.
 * It shares the grid's thumbnail plugin, so the bitmap is already rendered.
 */
function PdfLibraryInfoPreview({
  documentId,
  loaded,
}: {
  documentId: string | null;
  loaded: boolean;
}) {
  const documentState = useDocumentState(documentId);
  const firstPage = documentState?.document?.pages[0];
  const aspectRatio = React.useMemo(() => {
    if (!firstPage) return FALLBACK_ASPECT_RATIO;
    const { width, height } = firstPage.size;
    const quarterTurns =
      (firstPage.rotation + (documentState?.rotation ?? 0)) % 2;
    const [w, h] = quarterTurns === 0 ? [width, height] : [height, width];
    return w > 0 && h > 0 ? w / h : FALLBACK_ASPECT_RATIO;
  }, [firstPage, documentState?.rotation]);

  if (!documentId || !loaded) {
    return (
      <div
        className="border-border text-muted-foreground flex items-center justify-center rounded-lg border border-dashed p-4 text-center text-xs"
        style={{ aspectRatio: FALLBACK_ASPECT_RATIO }}
      >
        Pick a document to inspect it
      </div>
    );
  }

  return (
    <div
      // A page preview, so it takes the page colour rather than a theme token.
      // See `PDF_PAGE_COLOR` in `<PdfViewerContent>`.
      className="ring-border relative mx-auto w-2/3 overflow-hidden rounded-lg bg-(--pdf-page,#fff) ring-1"
      style={{ aspectRatio }}
    >
      <ThumbImg
        documentId={documentId}
        meta={{
          pageIndex: 0,
          width: 0,
          height: 0,
          wrapperHeight: 0,
          top: 0,
          labelHeight: 0,
        }}
        className="relative size-full object-contain"
      />
    </div>
  );
}