Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 62 additions & 19 deletions src/components/clinical-dashboard/image-lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,63 @@ const MIN_SCALE = 1;
const MAX_SCALE = 6;
const clampScale = (value: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, value));

type ImageLightboxBaseProps = {
open: boolean;
onClose: () => void;
alt: string;
caption?: string;
returnFocusRef?: RefObject<HTMLElement | null>;
};

/**
* Endpoint mode: fetch a private image through `/api/.../signed-url` (rail crops).
* URL mode: parent already owns a document signed URL (whole-document images).
* Exactly one source must be provided — URL mode must never touch the signed-URL LRU.
*/
export type ImageLightboxProps = ImageLightboxBaseProps &
({ endpoint: string; url?: never } | { url: string; endpoint?: never });

/**
* Fullscreen, zoomable viewer for a single private image (diagram / table crop).
* Fullscreen, zoomable viewer for a single private image (diagram / table crop /
* whole-document photo).
*
* Built on the shared Sheet (focus trap, Escape, scroll-lock, focus return) and
* the shared useViewerGestures hook, so wheel/pinch zoom and drag-to-pan match
* the PDF canvas. Zoom/pan/rotate are pure CSS transforms on the <img>.
*/
export function ImageLightbox({
open,
onClose,
endpoint,
alt,
caption,
returnFocusRef,
}: {
open: boolean;
onClose: () => void;
endpoint: string;
alt: string;
caption?: string;
returnFocusRef?: RefObject<HTMLElement | null>;
}) {
const { url, failed, retry, markFailed } = useSignedImageUrl(endpoint, open);
export function ImageLightbox(props: ImageLightboxProps) {
const { open, onClose, alt, caption, returnFocusRef } = props;
const endpoint = "endpoint" in props ? props.endpoint : undefined;
const directUrl = "url" in props ? props.url : undefined;
const endpointMode = typeof endpoint === "string" && endpoint.length > 0;

const {
url: fetchedUrl,
failed: fetchFailed,
retry: retryFetch,
markFailed: markFetchFailed,
} = useSignedImageUrl(endpoint ?? "", open && endpointMode);

const [directFailed, setDirectFailed] = useState(false);
const [retryDisabled, setRetryDisabled] = useState(false);
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
const [translate, setTranslate] = useState({ x: 0, y: 0 });
const stageRef = useRef<HTMLDivElement>(null);
const scaleRef = useRef(1);
// Fresh direct URL after parent re-issue clears a prior load failure during render
// (same identity-adjust pattern as useSignedImageUrl — no set-state-in-effect).
const [seenDirectUrl, setSeenDirectUrl] = useState(directUrl ?? null);
if (!endpointMode && (directUrl ?? null) !== seenDirectUrl) {
setSeenDirectUrl(directUrl ?? null);
setDirectFailed(false);
}

// URL mode: blank when the parent clears the signed URL (auth identity change).
// Never seed from the module LRU — that would revive a prior identity's image.
// Hide the <img> once a load failure is recorded so role=alert can surface.
const url = endpointMode ? fetchedUrl : open && directUrl && !directFailed ? directUrl : null;
const failed = endpointMode ? fetchFailed : directFailed;

useEffect(() => {
scaleRef.current = scale;
Expand All @@ -51,15 +79,28 @@ export function ImageLightbox({
const handleRetry = useCallback(() => {
if (retryDisabled) return;
setRetryDisabled(true);
retry();
if (endpointMode) {
retryFetch();
} else {
setDirectFailed(false);
}
setTimeout(() => setRetryDisabled(false), 2000);
}, [retryDisabled, retry]);
}, [endpointMode, retryDisabled, retryFetch]);

const markFailed = useCallback(() => {
if (endpointMode) {
markFetchFailed();
return;
}
setDirectFailed(true);
}, [endpointMode, markFetchFailed]);

// Reset the view on close so the next open never inherits a prior image's zoom.
const handleClose = useCallback(() => {
setScale(1);
setRotation(0);
setTranslate({ x: 0, y: 0 });
setDirectFailed(false);
onClose();
}, [onClose]);
Comment thread
BigSimmo marked this conversation as resolved.

Expand Down Expand Up @@ -104,6 +145,8 @@ export function ImageLightbox({
<div
ref={stageRef}
{...handlers}
data-testid="image-lightbox-stage"
data-source-mode={endpointMode ? "endpoint" : "url"}
className={cn(
"relative flex h-full min-h-[62vh] w-full select-none items-center justify-center overflow-hidden bg-[color:var(--surface-inset)] [touch-action:none] lg:min-h-[70vh]",
zoomed && "cursor-grab active:cursor-grabbing",
Expand Down
65 changes: 53 additions & 12 deletions src/components/document-viewer/non-pdf-source-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
/* eslint-disable @next/next/no-img-element */

import { memo, useEffect, useId, useRef, useState } from "react";
import { CircleAlert, Download, ExternalLink, FileText, RefreshCw } from "lucide-react";
import { CircleAlert, Download, ExternalLink, FileText, Maximize2, RefreshCw } from "lucide-react";

import { ImageLightbox } from "@/components/clinical-dashboard/image-lightbox";
import { cn, floatingControl } from "@/components/ui-primitives";
import { announce } from "@/components/ui/live-announcer";

Expand All @@ -17,7 +18,7 @@ const placeholderSurface =
* Inline preview for non-PDF source documents.
*
* PDFs render in PdfCanvasViewer/NativePdfEmbed; everything else lands here:
* - image/* → the source image inline (native browser view for full-size/zoom),
* - image/* → inline stage + shared ImageLightbox (same gestures as rail crops),
* - text/* → a pointer to the already-extracted indexed text below,
* - other (DOCX/XLSX/…) → an honest "download to view" affordance,
* - no signed URL yet → the original placeholder.
Expand Down Expand Up @@ -92,9 +93,8 @@ export const NonPdfSourcePreview = memo(function NonPdfSourcePreview({

/**
* Inline image with a failure fallback. The source is a direct signed URL owned
* by the parent (not a re-fetchable endpoint), so on an expired/broken URL it
* surfaces the same Open/Download recovery affordance rather than a silently
* broken <img>.
* by the parent (not a re-fetchable endpoint). Primary viewing opens the shared
* ImageLightbox in URL mode; Open/Download remain secondary recovery affordances.
*/
function InlineImagePreview({
signedUrl,
Expand All @@ -106,8 +106,10 @@ function InlineImagePreview({
title: string;
}) {
const [failed, setFailed] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const announcementSourceId = useId();
const failureTransition = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (!failed) return;
Expand Down Expand Up @@ -153,21 +155,60 @@ function InlineImagePreview({
}

return (
<div className="flex flex-col items-center gap-3 bg-[color:var(--surface-inset)] p-3 sm:p-4">
<div className="relative w-full aspect-[4/3] overflow-hidden rounded-lg bg-[color:var(--surface)] shadow-[var(--shadow-tight)]">
<div className="flex flex-col items-center gap-3 bg-[color:var(--surface-inset)] p-2 sm:p-3">
<div className="relative w-full min-h-64 overflow-hidden bg-[color:var(--surface-inset)] sm:min-h-72">
<img
src={signedUrl}
alt={title}
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
className="absolute inset-0 h-full w-full object-contain"
className="mx-auto max-h-[min(70vh,36rem)] w-full object-contain"
/>
<button
ref={triggerRef}
type="button"
onClick={() => setLightboxOpen(true)}
aria-label={`Expand image: ${title}`}
className="absolute inset-0 z-10 flex cursor-zoom-in items-start justify-end p-2 focus-visible:outline-2 focus-visible:outline-[color:var(--focus)]"
>
<span
aria-hidden="true"
className="rounded-md border border-[color:var(--border)] bg-[color:var(--surface)]/85 p-1 text-[color:var(--text-muted)] shadow-[var(--shadow-tight)] backdrop-blur-md"
>
<Maximize2 aria-hidden="true" className="h-3.5 w-3.5" />
</span>
</button>
Comment thread
BigSimmo marked this conversation as resolved.
</div>
<a href={signedUrl} target="_blank" rel="noreferrer" className={secondaryButton}>
<ExternalLink aria-hidden="true" className="h-4 w-4" />
Open full image
</a>
<div className="flex flex-wrap items-center justify-center gap-2">
<button type="button" onClick={() => setLightboxOpen(true)} className={cn(secondaryButton, "min-h-tap")}>
<Maximize2 aria-hidden="true" className="h-4 w-4" />
View immersive
</button>
<a href={signedUrl} target="_blank" rel="noreferrer" className={cn(secondaryButton, "min-h-tap")}>
<ExternalLink aria-hidden="true" className="h-4 w-4" />
Open
</a>
{downloadSignedUrl ? (
<a
href={downloadSignedUrl}
target="_blank"
rel="noreferrer"
download
className={cn(secondaryButton, "min-h-tap")}
>
<Download aria-hidden="true" className="h-4 w-4" />
Download
</a>
) : null}
</div>
<ImageLightbox
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
url={signedUrl}
alt={title}
returnFocusRef={triggerRef}
/>
</div>
);
}
58 changes: 57 additions & 1 deletion tests/document-viewer-non-pdf-preview.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview";
import { LiveAnnouncer, resetAnnouncerForTests } from "@/components/ui/live-announcer";

vi.mock("@/lib/supabase/client", () => ({
useAuthSession: () => ({
status: "authenticated",
session: { user: { id: "user-a" } },
authorizationHeader: { Authorization: "Bearer user-a" },
markSessionExpired: vi.fn(),
registerAuthRequest: vi.fn(() => ({ epoch: 1, release: vi.fn() })),
isAuthEpochCurrent: vi.fn(() => true),
}),
}));

describe("DocumentViewer non-PDF image preview", () => {
beforeEach(() => resetAnnouncerForTests());

Expand All @@ -12,6 +23,29 @@ describe("DocumentViewer non-PDF image preview", () => {
vi.useRealTimers();
});

it("opens the shared immersive lightbox from the primary expand control", async () => {
render(
<NonPdfSourcePreview
fileType="image/png"
title="Clinical chart"
signedUrl="https://example.test/chart.png"
downloadSignedUrl="https://example.test/chart-download.png"
/>,
);

expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Expand image: Clinical chart" }));
expect(await screen.findByTestId("image-lightbox")).toBeInTheDocument();
expect(screen.getByTestId("image-lightbox-stage")).toHaveAttribute("data-source-mode", "url");
expect(screen.getAllByRole("img", { name: "Clinical chart" }).length).toBeGreaterThanOrEqual(1);

expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "https://example.test/chart.png");
expect(screen.getByRole("link", { name: "Download" })).toHaveAttribute(
"href",
"https://example.test/chart-download.png",
);
});

it("observably announces sub-second fail-retry-fail transitions while same-state rerenders stay silent", () => {
vi.useFakeTimers();
const props = {
Expand Down Expand Up @@ -53,4 +87,26 @@ describe("DocumentViewer non-PDF image preview", () => {
act(() => vi.advanceTimersByTime(50));
expect(assertiveRegion).toHaveTextContent("Image preview could not load");
});

it("unmounts the lightbox when the parent blanks the signed URL", async () => {
const { rerender } = render(
<NonPdfSourcePreview
fileType="image/png"
title="Clinical chart"
signedUrl="https://example.test/chart.png"
downloadSignedUrl={null}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "View immersive" }));
expect(await screen.findByTestId("image-lightbox")).toBeInTheDocument();

rerender(
<NonPdfSourcePreview fileType="image/png" title="Clinical chart" signedUrl={null} downloadSignedUrl={null} />,
);

await waitFor(() => {
expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument();
});
expect(screen.getByText(/signed URL is generated/i)).toBeInTheDocument();
});
});
73 changes: 73 additions & 0 deletions tests/image-lightbox-url-mode.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** @vitest-environment jsdom */

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

import { ImageLightbox } from "@/components/clinical-dashboard/image-lightbox";
import { clearSignedUrlCache, setCachedSignedUrl } from "@/lib/signed-url-cache";

const ENDPOINT = "/api/images/lightbox-url-mode/signed-url";
const PRIOR_USER_URL = "https://example.supabase.co/storage/v1/object/sign/prior-user.png?token=stale";
const DIRECT_URL = "https://example.test/whole-document.png";

vi.mock("@/lib/supabase/client", () => ({
useAuthSession: () => ({
status: "authenticated",
session: { user: { id: "user-b" } },
authorizationHeader: { Authorization: "Bearer user-b" },
markSessionExpired: vi.fn(),
registerAuthRequest: vi.fn(() => ({ epoch: 1, release: vi.fn() })),
isAuthEpochCurrent: vi.fn(() => true),
}),
}));

describe("ImageLightbox URL mode", () => {
beforeEach(() => {
clearSignedUrlCache();
});

afterEach(() => {
clearSignedUrlCache();
});

it("renders a parent-owned URL without reading the signed-URL LRU", async () => {
setCachedSignedUrl(ENDPOINT, { url: PRIOR_USER_URL, expiresAt: new Date(Date.now() + 60_000).toISOString() });

render(<ImageLightbox open onClose={vi.fn()} url={DIRECT_URL} alt="Whole document chart" caption="Chart" />);

const stage = await screen.findByTestId("image-lightbox-stage");
expect(stage).toHaveAttribute("data-source-mode", "url");
const image = screen.getByRole("img", { name: "Whole document chart" });
expect(image).toHaveAttribute("src", DIRECT_URL);
expect(image).not.toHaveAttribute("src", PRIOR_USER_URL);
});

it("blanks the stage when the parent clears the direct URL while open", async () => {
const { rerender } = render(<ImageLightbox open onClose={vi.fn()} url={DIRECT_URL} alt="Whole document chart" />);
expect(screen.getByRole("img", { name: "Whole document chart" })).toBeInTheDocument();

// Parent auth-clear: signed URL gone. URL mode must not revive PRIOR_USER_URL from LRU.
setCachedSignedUrl(ENDPOINT, { url: PRIOR_USER_URL, expiresAt: new Date(Date.now() + 60_000).toISOString() });
rerender(<ImageLightbox open onClose={vi.fn()} url="" alt="Whole document chart" />);

await waitFor(() => {
expect(screen.queryByRole("img", { name: "Whole document chart" })).not.toBeInTheDocument();
});
expect(screen.getByRole("status")).toHaveTextContent("Loading image");
expect(screen.queryByRole("img")).toBeNull();
});

it("announces load failure with role=alert and retries without endpoint fetch", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
render(<ImageLightbox open onClose={vi.fn()} url={DIRECT_URL} alt="Broken chart" />);

fireEvent.error(screen.getByRole("img", { name: "Broken chart" }));
expect(screen.getByRole("alert")).toHaveTextContent("Image could not load.");
expect(fetchSpy).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("img", { name: "Broken chart" })).toHaveAttribute("src", DIRECT_URL);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
Loading