Skip to content
2 changes: 2 additions & 0 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,8 @@ export function DocumentViewer({
reviewingTableFactId={reviewingTableFactId}
onReviewTableFact={reviewTableFact}
indexHealth={indexHealth}
activePage={activePage}
onSelectPage={navigateToPage}
/>
</section>
{readyDocument ? (
Expand Down
67 changes: 67 additions & 0 deletions src/components/document-viewer/document-image-filmstrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { cn, textMuted } from "@/components/ui-primitives";
import type { ImageRow } from "@/components/document-viewer/types";

/**
* Compact page-linked figure strip for the document rail.
* Clicks call `onSelectPage` (wired to navigateToPage) — never remount the PDF viewer.
* Does not fetch signed images; detailed SignedImage cards below keep deferred IO.
*/
export function DocumentImageFilmstrip({
images,
activePage,
onSelectPage,
}: {
images: ImageRow[];
activePage: number;
onSelectPage: (page: number) => void;
}) {
if (images.length === 0) return null;

return (
<div
role="toolbar"
aria-label="Jump PDF to figure page"
data-testid="document-image-filmstrip"
className="flex min-w-0 gap-1.5 overflow-x-auto pb-1 [-webkit-overflow-scrolling:touch]"
>
{images.map((image, index) => {
const page = typeof image.page_number === "number" && image.page_number >= 1 ? image.page_number : null;
const isActive = page !== null && page === activePage;
const labelSeed =
[image.tableLabel, image.tableTitle, image.caption].find((value) => Boolean(value?.trim()))?.trim() ??
image.image_type?.replaceAll("_", " ") ??
`Figure ${index + 1}`;
const shortLabel = labelSeed.length > 28 ? `${labelSeed.slice(0, 27)}…` : labelSeed;

return (
<button
key={image.id}
type="button"
disabled={page === null}
aria-current={isActive ? "page" : undefined}
aria-label={page === null ? `${labelSeed} — page unknown` : `Show PDF page ${page} for ${labelSeed}`}
title={page === null ? "Page unknown" : `Go to page ${page}`}
onClick={() => {
if (page !== null) onSelectPage(page);
}}
className={cn(
"inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-xs font-semibold transition",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
"disabled:cursor-not-allowed disabled:opacity-45",
isActive
? "border-[color:var(--clinical-accent)]/40 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
)}
>
<span className={cn("nums shrink-0", isActive ? undefined : textMuted)}>
{page === null ? "—" : `p.${page}`}
</span>
<span className="max-w-[9rem] truncate">{shortLabel}</span>
</button>
);
})}
</div>
);
}
14 changes: 12 additions & 2 deletions src/components/document-viewer/document-rail-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PinnedSourceEvidence,
TableReviewPanel,
} from "@/components/document-viewer/source-panels";
import { DocumentImageFilmstrip } from "@/components/document-viewer/document-image-filmstrip";
import type { ChunkRow, DocumentIndexHealth, ImageRow, TableFactRow } from "@/components/document-viewer/types";
import type { DocumentSection } from "@/components/document-viewer/section-index";
import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge";
Expand Down Expand Up @@ -58,6 +59,8 @@ export function DocumentViewerRail({
reviewingTableFactId,
onReviewTableFact,
indexHealth,
activePage,
onSelectPage,
}: {
headerHidden: boolean;
documentSections: DocumentSection[];
Expand All @@ -83,6 +86,8 @@ export function DocumentViewerRail({
reviewingTableFactId: string | null;
onReviewTableFact: (fact: TableFactRow, reviewClass: string) => void;
indexHealth: DocumentIndexHealth | null;
activePage: number;
onSelectPage: (page: number) => void;
}) {
return (
<aside
Expand Down Expand Up @@ -253,7 +258,12 @@ export function DocumentViewerRail({
live="polite"
/>
) : (
clinicalImages.map((image) => <DocumentImage key={image.id} image={image} />)
<>
<DocumentImageFilmstrip images={clinicalImages} activePage={activePage} onSelectPage={onSelectPage} />
{clinicalImages.map((image) => (
<DocumentImage key={image.id} image={image} activePage={activePage} onSelectPage={onSelectPage} />
))}
</>
)}
{!effectiveLoadingDocument && auditImages.length > 0 ? (
<details className={cn(sourceCard, "p-3")}>
Expand All @@ -262,7 +272,7 @@ export function DocumentViewerRail({
</summary>
<div className="mt-3 grid gap-3">
{auditImages.map((image) => (
<DocumentImage key={image.id} image={image} />
<DocumentImage key={image.id} image={image} activePage={activePage} onSelectPage={onSelectPage} />
))}
</div>
</details>
Expand Down
67 changes: 57 additions & 10 deletions src/components/document-viewer/source-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,20 @@ function tableQualityWarnings(image: ImageRow, hasStructuredTable: boolean) {
return warnings;
}

export function DocumentImage({ image }: { image: ImageRow }) {
export function DocumentImage({
image,
activePage,
onSelectPage,
}: {
image: ImageRow;
/** When set, highlights figures that belong to the active PDF page. */
activePage?: number;
/** Jump the PDF reader to this figure's page without remounting the viewer. */
onSelectPage?: (page: number) => void;
}) {
const endpoint = `/api/images/${image.id}/signed-url`;
const pageNumber = typeof image.page_number === "number" && image.page_number >= 1 ? image.page_number : null;
const isActivePage = pageNumber !== null && pageNumber === activePage;

const tableHeading = sourceTextForCompactDisplay([image.tableLabel, image.tableTitle].filter(Boolean).join(": "));
const cleanCaption = image.caption ? sourceTextForCompactDisplay(image.caption) : "";
Expand Down Expand Up @@ -314,15 +326,50 @@ export function DocumentImage({ image }: { image: ImageRow }) {
</figcaption>
);
return (
<figure className={cn(sourceCard, "overflow-hidden p-3")}>
<p className={cn("text-xs font-semibold uppercase tracking-eyebrow", textMuted)}>
page {image.page_number ?? "n/a"}
{image.image_type ? ` · ${image.image_type.replaceAll("_", " ")}` : ""}
{image.tableRole ? ` · ${image.tableRole}` : ""}
{image.clinicalUseClass && image.clinicalUseClass !== "clinical_evidence"
? ` · ${image.clinicalUseClass.replaceAll("_", " ")}`
: ""}
</p>
<figure
data-testid="document-image"
data-page={pageNumber ?? undefined}
data-active-page={isActivePage ? "true" : undefined}
className={cn(
sourceCard,
"overflow-hidden p-3",
isActivePage && "border-[color:var(--clinical-accent)]/35 ring-1 ring-[color:var(--clinical-accent)]/25",
)}
>
<div className="flex flex-wrap items-center gap-2">
{onSelectPage && pageNumber !== null ? (
<button
type="button"
onClick={() => onSelectPage(pageNumber)}
aria-label={`Show PDF page ${pageNumber}`}
aria-current={isActivePage ? "page" : undefined}
className={cn(
"inline-flex min-h-tap items-center rounded-md border px-2.5 text-xs font-semibold uppercase tracking-eyebrow transition",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
isActivePage
? "border-[color:var(--clinical-accent)]/40 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
)}
>
page {pageNumber}
</button>
) : (
<p className={cn("text-xs font-semibold uppercase tracking-eyebrow", textMuted)}>
page {image.page_number ?? "n/a"}
</p>
)}
<p className={cn("text-xs font-semibold uppercase tracking-eyebrow", textMuted)}>
{[
image.image_type ? image.image_type.replaceAll("_", " ") : null,
image.tableRole || null,
image.clinicalUseClass && image.clinicalUseClass !== "clinical_evidence"
? image.clinicalUseClass.replaceAll("_", " ")
: null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{warnings.length ? (
<div className="mt-2 rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)] p-2 text-xs leading-5 text-[color:var(--warning)]">
<p className="font-semibold">Verify table formatting against the source.</p>
Expand Down
18 changes: 18 additions & 0 deletions tests/document-detail-performance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,22 @@ describe("document viewer latency guards", () => {
expect(panels).toContain("const searchEligible = normalizedSearch.length >= 2;");
expect(panels).toContain("const displayChunks = useMemo(");
});

it("wires rail filmstrip page jumps through navigateToPage without remounting the PDF viewer", () => {
const viewer = source("src/components/DocumentViewer.tsx");
const rail = source("src/components/document-viewer/document-rail-panels.tsx");
const filmstrip = source("src/components/document-viewer/document-image-filmstrip.tsx");
const routeHook = source("src/components/document-viewer/use-document-viewer-route.ts");

expect(viewer).toContain("onSelectPage={navigateToPage}");
expect(viewer).toContain("activePage={activePage}");
expect(rail).toContain("DocumentImageFilmstrip");
expect(rail).toContain("onSelectPage={onSelectPage}");
expect(filmstrip).toContain('data-testid="document-image-filmstrip"');
expect(routeHook).toContain("window.history.pushState");
expect(viewer).not.toContain("router.push(documentPageHref");
// Page must not be part of the canvas key — that remounts pdf.js on every flip.
expect(viewer).toContain('key={`${documentId}-${useNativePdfViewer ? "native" : "canvas"}`}');
expect(viewer).not.toMatch(/key=\{`\$\{documentId\}.*\$\{activePage\}/);
});
});
77 changes: 77 additions & 0 deletions tests/document-image-filmstrip.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/** @vitest-environment jsdom */

import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { DocumentImageFilmstrip } from "@/components/document-viewer/document-image-filmstrip";
import { DocumentImage } from "@/components/document-viewer/source-panels";
import type { ImageRow } from "@/components/document-viewer/types";

vi.mock("@/components/clinical-dashboard/signed-image", () => ({
SignedImage: ({ alt }: { alt: string }) => <div data-testid="signed-image-stub">{alt}</div>,
}));

function image(overrides: Partial<ImageRow> & Pick<ImageRow, "id" | "page_number">): ImageRow {
return {
id: overrides.id,
page_number: overrides.page_number,
caption: overrides.caption ?? "",
image_type: overrides.image_type ?? "table_crop",
tableLabel: overrides.tableLabel ?? null,
tableTitle: overrides.tableTitle ?? null,
tableRole: overrides.tableRole ?? null,
clinicalUseClass: overrides.clinicalUseClass ?? "clinical_evidence",
clinicalUseReason: overrides.clinicalUseReason ?? null,
labels: overrides.labels ?? [],
width: overrides.width ?? 400,
height: overrides.height ?? 300,
source_kind: overrides.source_kind ?? "table_crop",
accessibleTableMarkdown: overrides.accessibleTableMarkdown ?? null,
tableTextSnippet: overrides.tableTextSnippet ?? null,
tableRows: overrides.tableRows ?? null,
tableColumns: overrides.tableColumns ?? null,
rowsTruncated: overrides.rowsTruncated ?? null,
rowCount: overrides.rowCount ?? null,
cropCompleteness: overrides.cropCompleteness ?? null,
structuredExtractionConfidence: overrides.structuredExtractionConfidence ?? null,
ocrTextDensity: overrides.ocrTextDensity ?? null,
};
}

describe("document image filmstrip page sync", () => {
it("calls onSelectPage for filmstrip chips and highlights the active page", () => {
const onSelectPage = vi.fn();
const images = [
image({ id: "img-1", page_number: 2, tableLabel: "ANC thresholds" }),
image({ id: "img-2", page_number: 5, caption: "Titration chart" }),
];

render(<DocumentImageFilmstrip images={images} activePage={5} onSelectPage={onSelectPage} />);

const toolbar = screen.getByRole("toolbar", { name: "Jump PDF to figure page" });
expect(toolbar).toHaveAttribute("data-testid", "document-image-filmstrip");

const page5 = within(toolbar).getByRole("button", { name: /Show PDF page 5 for Titration chart/i });
expect(page5).toHaveAttribute("aria-current", "page");
fireEvent.click(within(toolbar).getByRole("button", { name: /Show PDF page 2 for ANC thresholds/i }));
expect(onSelectPage).toHaveBeenCalledExactlyOnceWith(2);
});

it("lets DocumentImage page badges jump the PDF without requiring a second signed-URL fetch", () => {
const onSelectPage = vi.fn();
render(
<DocumentImage
image={image({ id: "img-9", page_number: 3, tableTitle: "Monitoring schedule" })}
activePage={3}
onSelectPage={onSelectPage}
/>,
);

const figure = screen.getByTestId("document-image");
expect(figure).toHaveAttribute("data-page", "3");
expect(figure).toHaveAttribute("data-active-page", "true");

fireEvent.click(screen.getByRole("button", { name: "Show PDF page 3" }));
expect(onSelectPage).toHaveBeenCalledExactlyOnceWith(3);
});
});
Loading