Viewer update and autozoom (#4800)

Updated embed PDF
Added Autozoom
Added file page size to metadata for use in calculations for autozoom,
will come in handy elsewhere.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Reece Browne
2025-11-10 13:52:13 +00:00
committed by GitHub
co-authored by James Brunton
parent 3cf89b6ede
commit ce6b2460d8
15 changed files with 1214 additions and 622 deletions
@@ -35,14 +35,12 @@ const EmbedPdfViewerContent = ({
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
const rotationState = getRotationState();
// Track initial rotation to detect changes
@@ -320,15 +318,6 @@ const EmbedPdfViewerContent = ({
<PdfViewerToolbar
currentPage={scrollState.currentPage}
totalPages={scrollState.totalPages}
onPageChange={(page) => {
// Page navigation handled by scrollActions
console.log('Navigate to page:', page);
}}
dualPage={spreadState.isDualPage}
onDualPageToggle={() => {
spreadActions.toggleSpreadMode();
}}
currentZoom={zoomState.zoomPercent}
/>
</div>
</div>
@@ -9,7 +9,7 @@ import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
import { ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react';
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
@@ -115,9 +115,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
defaultZoomLevel: 1.4, // Start at 140% zoom for better readability
defaultZoomLevel: ZoomMode.FitWidth, // Start with FitWidth, will be adjusted in ZoomAPIBridge
minZoom: 0.2,
maxZoom: 3.0,
maxZoom: 5.0,
}),
// Register tiling plugin (depends on Render, Scroll, Viewport)
@@ -287,6 +287,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
minHeight: 0,
minWidth: 0,
contain: 'strict',
display: 'flex',
justifyContent: 'center',
}}
>
<Scroller
@@ -295,6 +297,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
<Rotate key={document?.id} pageSize={{ width, height }}>
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
<div
data-page-index={pageIndex}
data-page-width={width}
data-page-height={height}
style={{
width,
height,
@@ -14,30 +14,32 @@ interface PdfViewerToolbarProps {
currentPage?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
// Dual page toggle (placeholder for now)
dualPage?: boolean;
onDualPageToggle?: () => void;
// Zoom controls (connected via ViewerContext)
currentZoom?: number;
}
export function PdfViewerToolbar({
currentPage = 1,
totalPages: _totalPages = 1,
onPageChange,
dualPage = false,
onDualPageToggle,
currentZoom: _currentZoom = 100,
}: PdfViewerToolbarProps) {
const { t } = useTranslation();
const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer();
const {
getScrollState,
getZoomState,
getSpreadState,
scrollActions,
zoomActions,
spreadActions,
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
} = useViewer();
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
const [pageInput, setPageInput] = useState(scrollState.currentPage || currentPage);
const [displayZoomPercent, setDisplayZoomPercent] = useState(zoomState.zoomPercent || 140);
const [isDualPageActive, setIsDualPageActive] = useState(spreadState.isDualPage);
// Register for immediate scroll updates and sync with actual scroll state
useEffect(() => {
@@ -53,6 +55,13 @@ export function PdfViewerToolbar({
setDisplayZoomPercent(zoomState.zoomPercent || 140);
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
useEffect(() => {
registerImmediateSpreadUpdate((_mode, isDual) => {
setIsDualPageActive(isDual);
});
setIsDualPageActive(spreadState.isDualPage);
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
const handleZoomOut = () => {
zoomActions.zoomOut();
};
@@ -69,6 +78,10 @@ export function PdfViewerToolbar({
setPageInput(page);
};
const handleDualPageToggle = () => {
spreadActions.toggleSpreadMode();
};
const handleFirstPage = () => {
scrollActions.scrollToFirstPage();
};
@@ -188,15 +201,19 @@ export function PdfViewerToolbar({
{/* Dual Page Toggle */}
<Button
variant={dualPage ? "filled" : "light"}
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={onDualPageToggle}
onClick={handleDualPageToggle}
style={{ minWidth: '2.5rem' }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
title={
isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")
}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
{/* Zoom Controls */}
@@ -7,33 +7,36 @@ import { useViewer } from '@app/contexts/ViewerContext';
*/
export function SpreadAPIBridge() {
const { provides: spread, spreadMode } = useSpread();
const { registerBridge } = useViewer();
const { registerBridge, triggerImmediateSpreadUpdate } = useViewer();
useEffect(() => {
if (spread) {
const newState = {
spreadMode,
isDualPage: spreadMode !== SpreadMode.None
};
// Register this bridge with ViewerContext
registerBridge('spread', {
state: newState,
api: {
setSpreadMode: (mode: SpreadMode) => {
spread.setSpreadMode(mode);
},
getSpreadMode: () => spread.getSpreadMode(),
toggleSpreadMode: () => {
// Toggle between None and Odd (most common dual-page mode)
const newMode = spreadMode === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None;
spread.setSpreadMode(newMode);
},
SpreadMode: SpreadMode, // Export enum for reference
}
});
if (!spread) {
return;
}
}, [spread, spreadMode]);
const newState = {
spreadMode,
isDualPage: spreadMode !== SpreadMode.None,
};
registerBridge('spread', {
state: newState,
api: {
setSpreadMode: (mode: SpreadMode) => {
spread.setSpreadMode(mode);
},
getSpreadMode: () => spread.getSpreadMode(),
toggleSpreadMode: () => {
const current = spread.getSpreadMode();
const nextMode = current === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None;
spread.setSpreadMode(nextMode);
},
SpreadMode,
},
});
triggerImmediateSpreadUpdate(spreadMode);
}, [spread, spreadMode, registerBridge, triggerImmediateSpreadUpdate]);
return null;
}
@@ -1,68 +1,246 @@
import { useEffect, useRef } from 'react';
import { useZoom } from '@embedpdf/plugin-zoom/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useZoom, ZoomMode } from '@embedpdf/plugin-zoom/react';
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useFileState } from '@app/contexts/FileContext';
import {
determineAutoZoom,
DEFAULT_FALLBACK_ZOOM,
DEFAULT_VISIBILITY_THRESHOLD,
measureRenderedPageRect,
useFitWidthResize,
ZoomViewport,
} from '@app/utils/viewerZoom';
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
/**
* Component that runs inside EmbedPDF context and manages zoom state locally
*/
export function ZoomAPIBridge() {
const { provides: zoom, state: zoomState } = useZoom();
const { spreadMode } = useSpread();
const { registerBridge, triggerImmediateZoomUpdate } = useViewer();
const { selectors } = useFileState();
const hasSetInitialZoom = useRef(false);
const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None);
const lastFileId = useRef<string | undefined>(undefined);
const lastAppliedZoom = useRef<number | null>(null);
const [autoZoomTick, setAutoZoomTick] = useState(0);
const scheduleAutoZoom = useCallback(() => {
hasSetInitialZoom.current = false;
lastAppliedZoom.current = null;
setAutoZoomTick((tick) => tick + 1);
}, []);
const requestFitWidth = useCallback(() => {
if (zoom) {
zoom.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 });
}
}, [zoom]);
const stubs = selectors.getStirlingFileStubs();
const firstFileStub = stubs[0];
const firstFileId = firstFileStub?.id;
// Set initial zoom once when plugin is ready
useEffect(() => {
if (!zoom || hasSetInitialZoom.current) {
if (!firstFileId) {
hasSetInitialZoom.current = false;
lastFileId.current = undefined;
lastAppliedZoom.current = null;
return;
}
let retryTimer: ReturnType<typeof setTimeout> | undefined;
const attemptInitialZoom = () => {
try {
zoom.requestZoom(1.4);
hasSetInitialZoom.current = true;
} catch (error) {
console.log('Zoom initialization delayed, viewport not ready:', error);
retryTimer = setTimeout(() => {
try {
zoom.requestZoom(1.4);
hasSetInitialZoom.current = true;
} catch (retryError) {
console.log('Zoom initialization failed:', retryError);
}
}, 200);
}
};
const timer = setTimeout(attemptInitialZoom, 50);
return () => {
clearTimeout(timer);
if (retryTimer) {
clearTimeout(retryTimer);
}
};
}, [zoom, zoomState]);
if (firstFileId !== lastFileId.current) {
lastFileId.current = firstFileId;
scheduleAutoZoom();
}
}, [firstFileId, scheduleAutoZoom]);
useEffect(() => {
if (zoom && zoomState) {
// Update local state
const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4;
const newState = {
currentZoom: currentZoomLevel,
zoomPercent: Math.round(currentZoomLevel * 100),
};
const currentSpreadMode = spreadMode ?? SpreadMode.None;
if (currentSpreadMode !== lastSpreadMode.current) {
lastSpreadMode.current = currentSpreadMode;
// Trigger immediate update for responsive UI
triggerImmediateZoomUpdate(newState.zoomPercent);
// Register this bridge with ViewerContext
registerBridge('zoom', {
state: newState,
api: zoom
});
const hadTrackedAutoZoom = lastAppliedZoom.current !== null;
const zoomLevel = zoomState?.zoomLevel;
if (
zoomLevel === ZoomMode.FitWidth ||
zoomLevel === ZoomMode.Automatic ||
hadTrackedAutoZoom
) {
requestFitWidth();
scheduleAutoZoom();
}
}
}, [zoom, zoomState]);
}, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]);
const getViewportSnapshot = useCallback((): ZoomViewport | null => {
if (!zoomState || typeof zoomState !== 'object') {
return null;
}
if ('viewport' in zoomState) {
const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport;
return candidate ?? null;
}
return null;
}, [zoomState]);
const isManagedZoom =
!!zoom &&
(zoomState?.zoomLevel === ZoomMode.FitWidth ||
zoomState?.zoomLevel === ZoomMode.Automatic ||
lastAppliedZoom.current !== null);
useFitWidthResize({
isManaged: isManagedZoom,
requestFitWidth,
onDebouncedResize: scheduleAutoZoom,
});
useEffect(() => {
if (!zoom || !zoomState) {
return;
}
if (!firstFileId) {
return;
}
if (hasSetInitialZoom.current) {
return;
}
if (zoomState.zoomLevel !== ZoomMode.FitWidth) {
if (zoomState.zoomLevel === ZoomMode.Automatic) {
requestFitWidth();
}
return;
}
const fitWidthZoom = zoomState.currentZoomLevel;
if (!fitWidthZoom || fitWidthZoom <= 0) {
return;
}
const applyTrackedZoom = (level: number | ZoomMode, effectiveZoom: number) => {
zoom.requestZoom(level, { vx: 0.5, vy: 0 });
lastAppliedZoom.current = effectiveZoom;
triggerImmediateZoomUpdate(Math.round(effectiveZoom * 100));
hasSetInitialZoom.current = true;
};
let cancelled = false;
const applyAutoZoom = async () => {
const currentSpreadMode = spreadMode ?? SpreadMode.None;
const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1;
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
const viewport = getViewportSnapshot();
if (cancelled) {
return;
}
const metrics = viewport ?? {};
const viewportWidth =
metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0;
const viewportHeight =
metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return;
}
const pageRect = await measureRenderedPageRect({
shouldCancel: () => cancelled,
});
if (cancelled) {
return;
}
const decision = determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect: pageRect
? { width: pageRect.width, height: pageRect.height }
: undefined,
metadataAspectRatio: metadataAspectRatio ?? null,
visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom: DEFAULT_FALLBACK_ZOOM,
});
if (decision.type === 'fallback') {
applyTrackedZoom(decision.zoom, decision.zoom);
return;
}
if (decision.type === 'fitWidth') {
applyTrackedZoom(ZoomMode.FitWidth, fitWidthZoom);
return;
}
applyTrackedZoom(decision.zoom, decision.zoom);
};
applyAutoZoom();
return () => {
cancelled = true;
};
}, [
zoom,
zoomState,
firstFileId,
firstFileStub,
requestFitWidth,
getViewportSnapshot,
autoZoomTick,
spreadMode,
triggerImmediateZoomUpdate,
]);
useEffect(() => {
if (!zoom) {
return;
}
const unsubscribe = zoom.onZoomChange((event: { newZoom?: number }) => {
if (typeof event?.newZoom !== 'number') {
return;
}
lastAppliedZoom.current = event.newZoom;
triggerImmediateZoomUpdate(Math.round(event.newZoom * 100));
});
return () => {
unsubscribe();
};
}, [zoom, triggerImmediateZoomUpdate]);
useEffect(() => {
if (!zoom || !zoomState) {
return;
}
const currentZoomLevel =
lastAppliedZoom.current ?? zoomState.currentZoomLevel ?? 1;
const newState = {
currentZoom: currentZoomLevel,
zoomPercent: Math.round(currentZoomLevel * 100),
};
triggerImmediateZoomUpdate(newState.zoomPercent);
registerBridge('zoom', {
state: newState,
api: zoom,
});
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
return null;
}