import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import type { FitMode } from "../../hooks/use-pdf-pages";
import {
clampZoom,
DEFAULT_ZOOM,
stepZoom,
type PdfFallbackReason,
} from "../../lib/pdf";
import { Button } from "../ui/button";
import { usePdfViewerInternalDeps } from "./pdf-viewer-deps";
import { PdfPageView } from "./PdfPageView";
import { PdfToolbar } from "./PdfToolbar";
/**
* Exact production contract (task .4 / R4).
* No hook-injection props — tests use pdf-viewer-deps TestDepsProvider.
*/
export type PdfViewerProps = {
assetUrl: string | null;
downloadUrl: string;
/**
* Exact predicate computed by DocView:
* contentAvailable && non-empty trimmed content string.
* Viewer never re-derives this.
*/
extractedTextAvailable: boolean;
onFallback: (reason: PdfFallbackReason) => void;
};
type StatePanelProps = {
testId: string;
eyebrow: string;
body: string;
downloadUrl: string;
/** loading/empty → status; error cards → alert */
role: "status" | "alert";
onRetry?: () => void;
actions?: ReactNode;
};
/**
* Flat state treatment inside the recessed well — no Card chrome
* (no rounded border / tinted surface / shadow box).
*/
function StatePanel({
testId,
eyebrow,
body,
downloadUrl,
role,
onRetry,
}: StatePanelProps) {
return (
);
}
const ERROR_EYEBROW: Record = {
corrupt: "CANNOT RENDER",
password: "PASSWORD PROTECTED",
network: "COULD NOT LOAD",
bootstrap: "VIEWER UNAVAILABLE",
};
const PAGE_ERROR_BODY: Record = {
corrupt: "This page could not be rendered.",
password: "This page is password protected.",
network: "This page could not be loaded from this session.",
bootstrap: "The PDF viewer could not render this page.",
};
type PdfPageErrorSlotProps = {
pageNumber: number;
width: number;
height: number;
error: PdfFallbackReason;
onMount: (pageNumber: number, el: HTMLElement | null) => void;
};
/**
* A later page whose geometry failed (R2): the page error state rendered in
* place of the page, at the slot's placeholder size so the scroll model and
* the visibility window keep their shape. Never fires the text fallback.
*/
function PdfPageErrorSlot({
pageNumber,
width,
height,
error,
onMount,
}: PdfPageErrorSlotProps) {
const rootRef = useRef(null);
useEffect(() => {
onMount(pageNumber, rootRef.current);
return () => {
onMount(pageNumber, null);
};
}, [pageNumber, onMount]);
return (
{ERROR_EYEBROW[error]}
Page {pageNumber}: {PAGE_ERROR_BODY[error]}
);
}
function prefersReducedMotion(): boolean {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return false;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/**
* User-facing PDF viewer: instrument rail + page column + designed states.
* DocView owns the Pages/Text toggle; this shell never renders it.
*/
export function PdfViewer({
assetUrl,
downloadUrl,
extractedTextAvailable,
onFallback,
}: PdfViewerProps) {
const { usePdfDocument, usePdfPages } = usePdfViewerInternalDeps();
const docState = usePdfDocument(assetUrl);
const { status, doc, numPages, error, docId, retry } = docState;
const [page, setPage] = useState(1);
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [fitMode, setFitMode] = useState("width");
const [genId, setGenId] = useState(1);
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
const columnRef = useRef(null);
const viewerRef = useRef(null);
const fallbackFiredForLoadRef = useRef(null);
const onFallbackRef = useRef(onFallback);
onFallbackRef.current = onFallback;
const bumpGen = useCallback(() => {
setGenId((g) => g + 1);
}, []);
// Measure page column for fit modes
useLayoutEffect(() => {
const el = columnRef.current;
if (!el || typeof ResizeObserver === "undefined") {
return;
}
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const { width, height } = entry.contentRect;
setContainerWidth(Math.max(0, Math.floor(width)));
setContainerHeight(Math.max(0, Math.floor(height)));
});
ro.observe(el);
setContainerWidth(Math.max(0, el.clientWidth));
setContainerHeight(Math.max(0, el.clientHeight));
return () => {
ro.disconnect();
};
}, [status, numPages]);
// Reset page/zoom when a new document loads
useEffect(() => {
if (status === "ready" && docId) {
setPage(1);
setZoom(DEFAULT_ZOOM);
setFitMode("width");
setGenId(1);
fallbackFiredForLoadRef.current = null;
}
}, [docId, status]);
// Real hook composition: genId from zoom/fit commits drives task .3 cancel path
const pages = usePdfPages({
doc: status === "ready" ? doc : null,
docId: status === "ready" ? docId : null,
numPages: status === "ready" ? numPages : 0,
zoom,
fitMode,
containerWidth,
containerHeight,
genId,
scrollContainerRef: columnRef,
});
// Fatal only: a document load failure or the hook's fatal error (page 1
// geometry, render-path acquisition). A later page's geometry failure rides
// on its slot and never reaches this path.
const viewerError = status === "error" ? error : pages.error;
// Fallback: exactly once per failed load/page attempt when extracted text exists.
useEffect(() => {
if (!viewerError || !extractedTextAvailable) {
return;
}
const key = `${docId ?? assetUrl ?? "err"}:${viewerError}`;
if (fallbackFiredForLoadRef.current === key) {
return;
}
fallbackFiredForLoadRef.current = key;
onFallbackRef.current(viewerError);
}, [viewerError, extractedTextAvailable, docId, assetUrl]);
const firstVisiblePage = pages.slots.find((slot) => slot.visible)?.pageNumber;
// Native scrolling is intentionally left to the browser. Keep toolbar and
// subsequent prev/next actions anchored to the first page currently visible
// in the column instead of the last explicitly requested page.
useEffect(() => {
if (firstVisiblePage === undefined || numPages < 1) {
return;
}
setPage(Math.min(numPages, Math.max(1, firstVisiblePage)));
}, [firstVisiblePage, numPages]);
// Keep page in range when numPages changes
useEffect(() => {
if (numPages < 1) {
return;
}
setPage((p) => Math.min(numPages, Math.max(1, p)));
}, [numPages]);
const scrollToPage = useCallback((target: number) => {
const el = columnRef.current?.querySelector(
`[data-testid="pdf-page-${target}"]`
);
if (!(el instanceof HTMLElement)) {
return;
}
el.scrollIntoView({
block: "start",
behavior: prefersReducedMotion() ? "auto" : "smooth",
});
}, []);
const goToPage = useCallback(
(next: number) => {
if (numPages < 1) {
return false;
}
const clamped = Math.min(numPages, Math.max(1, Math.trunc(next)));
if (clamped === page) {
return false;
}
setPage(clamped);
requestAnimationFrame(() => {
scrollToPage(clamped);
});
return true;
},
[numPages, page, scrollToPage]
);
// Boundary: when step cannot change zoom, no state/gen change (any fit mode)
const zoomIn = useCallback(() => {
const next = stepZoom(zoom, 1);
if (next === zoom) {
return false;
}
setZoom(next);
setFitMode("custom");
bumpGen();
return true;
}, [zoom, bumpGen]);
/**
* Commit an exact zoom level from the zoom-level combobox. Does exactly what
* zoomIn/zoomOut already do — no new zoom math. Matching the accepted
* boundary rule, an already-current level in `custom` fit mode makes no state
* or generation change.
*/
const zoomTo = useCallback(
(level: number) => {
const next = clampZoom(level);
if (next === zoom && fitMode === "custom") {
return false;
}
setZoom(next);
setFitMode("custom");
bumpGen();
return true;
},
[zoom, fitMode, bumpGen]
);
const zoomOut = useCallback(() => {
const next = stepZoom(zoom, -1);
if (next === zoom) {
return false;
}
setZoom(next);
setFitMode("custom");
bumpGen();
return true;
}, [zoom, bumpGen]);
const zoomReset = useCallback(() => {
if (zoom === DEFAULT_ZOOM && fitMode === "custom") {
return false;
}
setZoom(DEFAULT_ZOOM);
setFitMode("custom");
bumpGen();
return true;
}, [zoom, fitMode, bumpGen]);
const setFit = useCallback(
(mode: "width" | "page") => {
if (fitMode === mode) {
return false;
}
setFitMode(mode);
bumpGen();
return true;
},
[fitMode, bumpGen]
);
const handleRetry = useCallback(() => {
fallbackFiredForLoadRef.current = null;
retry();
}, [retry]);
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase() ?? "";
const inInput =
tag === "input" ||
tag === "textarea" ||
target?.isContentEditable === true;
if (
e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "Home" ||
e.key === "End" ||
e.key === " " ||
e.key === "Spacebar"
) {
return;
}
if (inInput) {
return;
}
const controlsLive = status === "ready" && numPages > 0;
let handled = false;
if (e.key === "PageDown" || e.key === "ArrowRight") {
if (controlsLive && page < numPages) {
handled = goToPage(page + 1);
}
} else if (e.key === "PageUp" || e.key === "ArrowLeft") {
if (controlsLive && page > 1) {
handled = goToPage(page - 1);
}
} else if (e.key === "+" || e.key === "=") {
if (controlsLive) {
handled = zoomIn();
}
} else if (e.key === "-" || e.key === "_") {
if (controlsLive) {
handled = zoomOut();
}
} else if (e.key === "0") {
if (controlsLive) {
handled = zoomReset();
}
}
if (handled) {
e.preventDefault();
}
},
[status, numPages, page, goToPage, zoomIn, zoomOut, zoomReset]
);
// ── Designed states (real usePdfDocument semantics) ──────────────────
// loading: only while status === "loading"
// empty: ready + zero pages (firstPageReady is false for zero-page docs)
// progressive: ready + numPages > 0
// error: status === "error"
const showLoading = status === "loading";
const showEmpty = status === "ready" && numPages === 0;
const showProgressive =
status === "ready" && numPages > 0 && viewerError === null;
const showError = viewerError !== null;
const showErrorPanel = showError && !extractedTextAvailable;
const errorPanel = (() => {
if (!showErrorPanel || !viewerError) {
return null;
}
switch (viewerError) {
case "corrupt":
return (
);
case "password":
return (
);
case "network":
return (
);
case "bootstrap":
return (
);
default:
return null;
}
})();
const toolbarDisabled = showEmpty || showLoading || showError;
return (
{
setFit(m);
}}
onPageChange={(p) => {
goToPage(p);
}}
onZoomIn={() => {
zoomIn();
}}
onZoomOut={() => {
zoomOut();
}}
onZoomTo={(level) => {
zoomTo(level);
}}
page={showProgressive ? page : 0}
zoom={zoom}
/>
{showLoading ? (
LOADING
Preparing document…
) : null}
{showEmpty ? (
) : null}
{errorPanel}
{showProgressive ? (
{pages.slots.map((slot) =>
slot.error ? (
) : (
)
)}
) : null}
);
}
// Re-export for tests / consumers
export type { FitMode };
export { clampZoom, stepZoom, DEFAULT_ZOOM };