diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index d919672354..2c7016317e 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -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"; @@ -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, @@ -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" @@ -1263,6 +1312,7 @@ export function DocumentViewer({ )} diff --git a/src/components/document-viewer/pdf-canvas-viewer.tsx b/src/components/document-viewer/pdf-canvas-viewer.tsx index dea77bfc3a..a394b52d15 100644 --- a/src/components/document-viewer/pdf-canvas-viewer.tsx +++ b/src/components/document-viewer/pdf-canvas-viewer.tsx @@ -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, @@ -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 @@ -56,6 +66,10 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({ onUrlExpired, onLoadSuccess, onPageChange, + fitWidth: fitWidthProp, + zoom: zoomProp, + onFitWidthChange, + onZoomChange, }: { url: string; title: string; @@ -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(null); const holderRef = useRef(null); @@ -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], + ); const [holderWidth, setHolderWidth] = useState(0); const [loading, setLoading] = useState(true); const [rendering, setRendering] = useState(false); @@ -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; @@ -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() { @@ -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; @@ -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(); @@ -452,32 +512,43 @@ export const PdfCanvasViewer = memo(function PdfCanvasViewer({