import { ChevronLeft, ChevronRight, Download, Maximize2, Minus, Plus, StretchHorizontal, } from "lucide-react"; import { useEffect, useId, useState, type ReactNode } from "react"; import type { FitMode } from "../../hooks/use-pdf-pages"; import { MAX_ZOOM, MIN_ZOOM } from "../../lib/pdf"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export type PdfToolbarProps = { page: number; numPages: number; /** Logical zoom (1 = 100%). */ zoom: number; fitMode: FitMode; /** When true, all interactive controls are disabled (empty / zero-page). */ disabled?: boolean; downloadUrl: string; onPageChange: (page: number) => void; onZoomIn: () => void; onZoomOut: () => void; /** Commit an exact zoom level (1 = 100%) from the zoom-level combobox. */ onZoomTo: (level: number) => void; onFitMode: (mode: "width" | "page") => void; }; /** * Fixed zoom stops, all inside the viewer's existing MIN_ZOOM (0.25) / * MAX_ZOOM (4) bounds — no new zoom math and no new bounds. 100% is the * default level and is marked as such in its accessible name. */ export const ZOOM_STOPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4] as const; function zoomStopLabel(level: number): string { return `${Math.round(level * 100)}%`; } function clampPage(raw: number, numPages: number): number { if (!Number.isFinite(raw) || numPages < 1) { return 1; } return Math.min(numPages, Math.max(1, Math.trunc(raw))); } /** * Digits-only finite values (including 0) are numeric and clamp to 1..N. * Empty / non-numeric → null (caller reverts, no callback). */ function parsePageDraft(draft: string, numPages: number): number | null { const trimmed = draft.trim(); if (!trimmed || !/^\d+$/u.test(trimmed)) { return null; } const n = Number(trimmed); if (!Number.isFinite(n)) { return null; } return clampPage(n, numPages); } function IconButton({ label, disabled, onClick, children, testId, pressed, }: { label: string; disabled?: boolean; onClick?: () => void; children: ReactNode; testId?: string; pressed?: boolean; }) { return ( {label} ); } /** * Scholarly Dusk instrument-rail toolbar. Pure/controlled — no pdfjs imports. * DocView owns the Pages/Text view toggle; this rail never renders it. */ export function PdfToolbar({ page, numPages, zoom, fitMode, disabled = false, downloadUrl, onPageChange, onZoomIn, onZoomOut, onZoomTo, onFitMode, }: PdfToolbarProps) { const liveId = useId(); const [draft, setDraft] = useState(String(page)); useEffect(() => { setDraft(String(page)); }, [page]); const atStart = page <= 1 || numPages < 1; const atEnd = page >= numPages || numPages < 1; const controlsDisabled = disabled || numPages < 1; const zoomPercent = Math.round(zoom * 100); const atMinZoom = zoom <= MIN_ZOOM; const atMaxZoom = zoom >= MAX_ZOOM; const commitDraft = () => { if (controlsDisabled) { setDraft(String(page)); return; } const parsed = parsePageDraft(draft, numPages); if (parsed === null) { setDraft(String(page)); return; } setDraft(String(parsed)); if (parsed !== page) { onPageChange(parsed); } }; return (
{/* Group A — page navigation */}
onPageChange(clampPage(page - 1, numPages))} testId="pdf-toolbar-prev" > setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitDraft(); } else if (e.key === "Escape") { e.preventDefault(); setDraft(String(page)); (e.target as HTMLInputElement).blur(); } }} value={draft} /> {Math.max(0, page)} {" "} / {Math.max(0, numPages)} onPageChange(clampPage(page + 1, numPages))} testId="pdf-toolbar-next" >
{/* Group B — zoom */}
{/* Zoom-level combobox. Deliberately inherits the previous readout's exact type treatment (font-mono text-xs tabular-nums) and h-8 sizing, with the primitive's border/shadow suppressed, so the toolbar's resting rhythm is unchanged next to the ghost stepper buttons — the control reads as the same numeral it always was, now operable. */}
{/* Structural {/* Group C — fit modes */}
{/* Group D — download */}
Download original
); }