0
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>
  );
}

Every document opens as a tab, and any view splits into resizable panes for side-by-side reading — each pane a self-contained viewer, with the shared sidebar following whichever pane you're working in. It's built on embedpdf's view-manager.