Skip to content
Merged
56 changes: 55 additions & 1 deletion src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ import {
import { useDocumentSummarize } from "@/components/document-viewer/use-document-summarize";
import { useDocumentViewerRoute } from "@/components/document-viewer/use-document-viewer-route";
import { usePdfViewerPreference } from "@/components/document-viewer/use-pdf-viewer-preference";
import { DocumentFrame, type DocumentFrameSource } from "@/components/ui/document-frame";
import { DocumentFrame, type DocumentFrameControls, type DocumentFrameSource } from "@/components/ui/document-frame";
import {
VIEWER_DEFAULT_ZOOM,
VIEWER_MAX_ZOOM,
VIEWER_MIN_ZOOM,
VIEWER_ZOOM_STEP,
} from "@/components/document-viewer/viewer-zoom";
import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache";
import { resolveScrollBehavior } from "@/lib/scroll-behavior";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
Expand Down Expand Up @@ -193,6 +199,19 @@ export function DocumentViewer({
);
const activeScrollOwner = useActiveScrollOwner(shellScrollContainer, documentId);
const { useNativePdfViewer, togglePdfViewerMode } = usePdfViewerPreference();
// Phase 2a: DocumentFrame owns zoom/fit/viewing-aid chrome for canvas PDF.
// Reset viewing chrome when the document identity changes (render-time adjust,
// not an effect — avoids react-hooks/set-state-in-effect).
const [pdfViewingDocumentId, setPdfViewingDocumentId] = useState(documentId);
const [pdfFitWidth, setPdfFitWidth] = useState(true);
const [pdfZoom, setPdfZoom] = useState(VIEWER_DEFAULT_ZOOM);
const [pdfViewingAid, setPdfViewingAid] = useState(false);
if (pdfViewingDocumentId !== documentId) {
setPdfViewingDocumentId(documentId);
setPdfFitWidth(true);
setPdfZoom(VIEWER_DEFAULT_ZOOM);
setPdfViewingAid(false);
}
const {
status: authStatus,
session,
Expand Down Expand Up @@ -770,6 +789,36 @@ export function DocumentViewer({
: document?.file_type?.startsWith("image/")
? { kind: "image", url: signedUrl ?? undefined }
: { kind: "document", url: signedUrl ?? undefined };
const canvasPdfReady =
Boolean(signedUrl) &&
document?.file_type === "application/pdf" &&
!useNativePdfViewer &&
!effectiveLoadingDocument &&
!effectiveViewerError &&
!previewError;
const handlePdfFitWidth = useCallback(() => {
setPdfFitWidth(true);
}, []);
const handlePdfZoomChange = useCallback((nextZoom: number) => {
setPdfFitWidth(false);
setPdfZoom(nextZoom);
}, []);
const handlePdfFitWidthChange = useCallback((nextFitWidth: boolean) => {
setPdfFitWidth(nextFitWidth);
}, []);
const pdfFrameControls: DocumentFrameControls | undefined = canvasPdfReady
? {
fitWidth: pdfFitWidth,
onFitWidth: handlePdfFitWidth,
zoom: pdfZoom,
onZoomChange: handlePdfZoomChange,
viewingAid: pdfViewingAid,
onViewingAidChange: setPdfViewingAid,
minZoom: VIEWER_MIN_ZOOM,
maxZoom: VIEWER_MAX_ZOOM,
zoomStep: VIEWER_ZOOM_STEP,
}
: undefined;
const headerTitle = readyDocument
? documentDisplayTitle(readyDocument)
: viewerState === "auth-required"
Expand Down Expand Up @@ -1263,6 +1312,7 @@ export function DocumentViewer({
<DocumentFrame
alt={`${document ? documentDisplayTitle(document) : "Source document"} preview`}
src={previewFrameSource}
controls={pdfFrameControls}
{...(effectiveLoadingDocument
? { state: "loading" as const, loadingLabel: "Preparing PDF preview" }
: effectiveViewerError || previewError
Expand Down Expand Up @@ -1341,6 +1391,10 @@ export function DocumentViewer({
onUrlExpired={handleSignedUrlExpired}
onLoadSuccess={handlePdfLoadSuccess}
onPageChange={navigateToPage}
fitWidth={pdfFitWidth}
zoom={pdfZoom}
onFitWidthChange={handlePdfFitWidthChange}
onZoomChange={handlePdfZoomChange}
/>
)}
</>
Expand Down
172 changes: 127 additions & 45 deletions src/components/document-viewer/pdf-canvas-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
"use client";

import { type KeyboardEvent as ReactKeyboardEvent, memo, useCallback, useEffect, useRef, useState } from "react";
import {
type KeyboardEvent as ReactKeyboardEvent,
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
ChevronLeft,
ChevronRight,
Expand All @@ -19,17 +27,19 @@ import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs
import { cn, floatingControl, toolbarButton } from "@/components/ui-primitives";
import { announce } from "@/components/ui/live-announcer";
import { useViewerGestures } from "@/components/document-viewer/use-viewer-gestures";
import {
resolveViewerZoomUpdate,
VIEWER_DEFAULT_ZOOM,
VIEWER_MAX_ZOOM,
VIEWER_MIN_ZOOM,
VIEWER_ZOOM_STEP,
} from "@/components/document-viewer/viewer-zoom";

const iconButton = toolbarButton;
const secondaryButton = floatingControl;

const MAX_FIT_SCALE = 2.8;
const MAX_ZOOM_SCALE = 4;
const MIN_ZOOM_SCALE = 0.55;
const MAX_RENDER_SCALE = 2.5;
const ZOOM_STEP = 0.15;

const clampZoom = (value: number) => Math.min(MAX_ZOOM_SCALE, Math.max(MIN_ZOOM_SCALE, value));

// A signed URL that has passed its (10-min) TTL fails pdf.js with an auth/HTTP
// error rather than a parse error. Detect those so the parent can re-issue a
Expand All @@ -56,6 +66,10 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
onUrlExpired,
onLoadSuccess,
onPageChange,
fitWidth: fitWidthProp,
zoom: zoomProp,
onFitWidthChange,
onZoomChange,
}: {
url: string;
title: string;
Expand All @@ -66,6 +80,14 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
onLoadSuccess?: () => void;
/** Keeps the document route in sync when the reader changes pages. */
onPageChange?: (page: number) => void;
/**
* Controlled fit/zoom from DocumentFrame (Phase 2a). When both change handlers
* are provided, Frame owns the chrome and this toolbar keeps page/rotate/fullscreen.
*/
fitWidth?: boolean;
zoom?: number;
onFitWidthChange?: (fitWidth: boolean) => void;
onZoomChange?: (zoom: number) => void;
}) {
const fullscreenRootRef = useRef<HTMLDivElement>(null);
const holderRef = useRef<HTMLDivElement>(null);
Expand All @@ -74,14 +96,49 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
const [page, setPage] = useState(initialPage);
const [pageInput, setPageInput] = useState(String(initialPage));
const [totalPages, setTotalPages] = useState(0);
const [zoom, setZoom] = useState(1.1);
const [internalZoom, setInternalZoom] = useState(VIEWER_DEFAULT_ZOOM);
// Debounced mirror of `zoom`. Zoom steps update `zoom` immediately (an interim
// CSS transform gives instant visual feedback) but only `renderZoom` drives the
// pdf.js raster, so rapid +/-, wheel, and pinch input re-rasterise once on
// settle instead of queueing a RenderTask per delta.
const [renderZoom, setRenderZoom] = useState(1.1);
const [renderZoom, setRenderZoom] = useState(VIEWER_DEFAULT_ZOOM);
const [rotation, setRotation] = useState(0);
const [fitWidth, setFitWidth] = useState(true);
const [internalFitWidth, setInternalFitWidth] = useState(true);
const frameOwnsZoomChrome = typeof onFitWidthChange === "function" && typeof onZoomChange === "function";
const fitWidth = frameOwnsZoomChrome ? Boolean(fitWidthProp) : internalFitWidth;
const zoom = frameOwnsZoomChrome ? (typeof zoomProp === "number" ? zoomProp : VIEWER_DEFAULT_ZOOM) : internalZoom;
// Eager refs so rapid functional updates (wheel/pinch) compose before React
// re-renders — especially on the Frame-owned path where zoom lives in a parent.
const fitWidthRef = useRef(fitWidth);
const zoomRef = useRef(zoom);
useLayoutEffect(() => {
fitWidthRef.current = fitWidth;
zoomRef.current = zoom;
}, [fitWidth, zoom]);
const setFitWidth = useCallback(
(next: boolean | ((current: boolean) => boolean)) => {
if (frameOwnsZoomChrome) {
const resolved = typeof next === "function" ? next(fitWidthRef.current) : next;
fitWidthRef.current = resolved;
onFitWidthChange?.(resolved);
return;
}
setInternalFitWidth((current) => (typeof next === "function" ? next(current) : next));
},
[frameOwnsZoomChrome, onFitWidthChange],
);
const setZoom = useCallback(
(next: number | ((current: number) => number)) => {
if (frameOwnsZoomChrome) {
const clamped = resolveViewerZoomUpdate(zoomRef.current, next);
zoomRef.current = clamped;
onZoomChange?.(clamped);
return;
}
setInternalZoom((current) => resolveViewerZoomUpdate(current, next));
},
[frameOwnsZoomChrome, onZoomChange],
);
Comment thread
cursor[bot] marked this conversation as resolved.
const [holderWidth, setHolderWidth] = useState(0);
const [loading, setLoading] = useState(true);
const [rendering, setRendering] = useState(false);
Expand Down Expand Up @@ -245,9 +302,9 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
const baseViewport = pdfPage.getViewport({ scale: 1, rotation });
const availableWidth = Math.max(220, holderRef.current.clientWidth - 16);
const requestedScale = fitWidth
? Math.min(MAX_FIT_SCALE, Math.max(MIN_ZOOM_SCALE, availableWidth / baseViewport.width))
? Math.min(MAX_FIT_SCALE, Math.max(VIEWER_MIN_ZOOM, availableWidth / baseViewport.width))
: renderZoom;
const viewportScale = Math.min(MAX_ZOOM_SCALE, Math.max(MIN_ZOOM_SCALE, requestedScale));
const viewportScale = Math.min(VIEWER_MAX_ZOOM, Math.max(VIEWER_MIN_ZOOM, requestedScale));
const outputScale = Math.min(MAX_RENDER_SCALE, window.devicePixelRatio || 1);
const viewport = pdfPage.getViewport({ scale: viewportScale * outputScale, rotation });
const canvas = canvasRef.current;
Expand Down Expand Up @@ -296,7 +353,7 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({

function zoomBy(delta: number) {
setFitWidth(false);
setZoom((current) => Number(clampZoom(current + delta).toFixed(2)));
setZoom((current) => Number((current + delta).toFixed(2)));
}

async function enterFullscreenFitView() {
Expand Down Expand Up @@ -338,10 +395,13 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
// sized by the container, so it never carries an interim scale.
const interimZoomScale = !fitWidth && renderZoom > 0 && zoom !== renderZoom ? zoom / renderZoom : 1;

const handleZoomByFactor = useCallback((factor: number) => {
setFitWidth(false);
setZoom((current) => Number(clampZoom(current * factor).toFixed(3)));
}, []);
const handleZoomByFactor = useCallback(
(factor: number) => {
setFitWidth(false);
setZoom((current) => Number((current * factor).toFixed(3)));
},
[setFitWidth, setZoom],
);

const handlePanByDelta = useCallback((dx: number, dy: number) => {
const holder = holderRef.current;
Expand Down Expand Up @@ -380,11 +440,11 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
case "+":
case "=":
event.preventDefault();
zoomBy(ZOOM_STEP);
zoomBy(VIEWER_ZOOM_STEP);
break;
case "-":
event.preventDefault();
zoomBy(-ZOOM_STEP);
zoomBy(-VIEWER_ZOOM_STEP);
break;
case "0":
event.preventDefault();
Expand Down Expand Up @@ -452,32 +512,43 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
<ChevronRight aria-hidden="true" className="h-4 w-4" />
</button>
<div className="flex shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-1 shadow-[var(--shadow-inset)] sm:ml-auto">
<button
onClick={() => zoomBy(-ZOOM_STEP)}
disabled={!pagesReady}
className={iconButton}
aria-label="Zoom out"
>
<Minus aria-hidden="true" className="h-4 w-4" />
</button>
<button
onClick={enterFullscreenFitView}
disabled={!pagesReady}
aria-label="Fit page width and enter fullscreen"
className={cn(
"inline-flex min-h-tap min-w-tap items-center justify-center gap-2 rounded-md border px-3 text-xs font-semibold transition",
"disabled:cursor-not-allowed disabled:opacity-45",
fitWidth || fullscreenActive
? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)]",
)}
>
<Maximize2 aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">Fit</span>
</button>
<button onClick={() => zoomBy(ZOOM_STEP)} disabled={!pagesReady} className={iconButton} aria-label="Zoom in">
<Plus aria-hidden="true" className="h-4 w-4" />
</button>
{frameOwnsZoomChrome ? null : (
<>
<button
onClick={() => zoomBy(-VIEWER_ZOOM_STEP)}
disabled={!pagesReady}
className={iconButton}
aria-label="Zoom out"
>
<Minus aria-hidden="true" className="h-4 w-4" />
</button>
<button
onClick={() => {
setFitWidth(true);
}}
disabled={!pagesReady}
aria-label="Fit page width"
className={cn(
"inline-flex min-h-tap min-w-tap items-center justify-center gap-2 rounded-md border px-3 text-xs font-semibold transition",
"disabled:cursor-not-allowed disabled:opacity-45",
fitWidth
? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)]",
)}
>
<Maximize2 aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">Fit</span>
</button>
<button
onClick={() => zoomBy(VIEWER_ZOOM_STEP)}
disabled={!pagesReady}
className={iconButton}
aria-label="Zoom in"
>
<Plus aria-hidden="true" className="h-4 w-4" />
</button>
</>
)}
<button
onClick={() => setRotation((current) => (current + 90) % 360)}
disabled={!pagesReady}
Expand All @@ -496,7 +567,18 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({
<Minimize2 aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">Exit</span>
</button>
) : null}
) : (
<button
onClick={enterFullscreenFitView}
disabled={!pagesReady}
aria-label="Enter fullscreen document view"
className={iconButton}
type="button"
>
<Maximize2 aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">Full</span>
</button>
)}
</div>
</div>

Expand Down
18 changes: 18 additions & 0 deletions src/components/document-viewer/viewer-zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Shared PDF/document viewing zoom contract (Phase 2a).
* DocumentFrame controls and PdfCanvasViewer must use the same clamp/step so
* Frame chrome and gesture/keyboard zoom stay aligned.
*/
export const VIEWER_MIN_ZOOM = 0.55;
export const VIEWER_MAX_ZOOM = 4;
export const VIEWER_ZOOM_STEP = 0.15;
export const VIEWER_DEFAULT_ZOOM = 1.1;

export function clampViewerZoom(value: number): number {
return Math.min(VIEWER_MAX_ZOOM, Math.max(VIEWER_MIN_ZOOM, value));
}

/** Resolve a zoom setter value against the latest known zoom (ref or React state). */
export function resolveViewerZoomUpdate(current: number, next: number | ((current: number) => number)): number {
return clampViewerZoom(typeof next === "function" ? next(current) : next);
}
7 changes: 4 additions & 3 deletions src/components/ui/document-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { CircleAlert, Eye, Loader2, Maximize2, Minus, Plus, RefreshCw } from "lucide-react";
import type { ReactNode } from "react";

import { VIEWER_MAX_ZOOM, VIEWER_MIN_ZOOM, VIEWER_ZOOM_STEP } from "@/components/document-viewer/viewer-zoom";
import { cn, textMuted } from "@/components/ui-primitives";

export type DocumentFrameSource =
Expand Down Expand Up @@ -68,9 +69,9 @@ function boundedZoom(value: number, minimum: number, maximum: number) {
}

function DocumentControls({ controls }: { controls: DocumentFrameControls }) {
const minimum = controls.minZoom ?? 0.5;
const maximum = controls.maxZoom ?? 4;
const step = controls.zoomStep ?? 0.25;
const minimum = controls.minZoom ?? VIEWER_MIN_ZOOM;
const maximum = controls.maxZoom ?? VIEWER_MAX_ZOOM;
const step = controls.zoomStep ?? VIEWER_ZOOM_STEP;
const zoom = boundedZoom(controls.zoom, minimum, maximum);
const zoomed = !controls.fitWidth;
const viewingAidActive = controls.viewingAid && !zoomed;
Expand Down
Loading
Loading