Stirling-PDF/frontend/src/core/utils/viewerZoom.ts
Reece Browne ce6b2460d8
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 <jbrunton96@gmail.com>
2025-11-10 13:52:13 +00:00

189 lines
4.8 KiB
TypeScript

import { useEffect, useRef } from 'react';
export const DEFAULT_VISIBILITY_THRESHOLD = 80; // Require at least 80% of the page height to be visible
export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present
export interface ZoomViewport {
clientWidth?: number;
clientHeight?: number;
width?: number;
height?: number;
}
export type AutoZoomDecision =
| { type: 'fallback'; zoom: number }
| { type: 'fitWidth' }
| { type: 'adjust'; zoom: number };
export interface AutoZoomParams {
viewportWidth: number;
viewportHeight: number;
fitWidthZoom: number;
pagesPerSpread: number;
pageRect?: { width: number; height: number } | null;
metadataAspectRatio?: number | null;
visibilityThreshold?: number;
fallbackZoom?: number;
}
export function determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect,
metadataAspectRatio,
visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom = DEFAULT_FALLBACK_ZOOM,
}: AutoZoomParams): AutoZoomDecision {
const rectWidth = pageRect?.width ?? 0;
const rectHeight = pageRect?.height ?? 0;
const aspectRatio: number | null =
rectWidth > 0 ? rectHeight / rectWidth : metadataAspectRatio ?? null;
let renderedHeight: number | null = rectHeight > 0 ? rectHeight : null;
if (!renderedHeight || renderedHeight <= 0) {
if (aspectRatio == null || aspectRatio <= 0) {
return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) };
}
const pageWidth = viewportWidth / (fitWidthZoom * pagesPerSpread);
const pageHeight = pageWidth * aspectRatio;
renderedHeight = pageHeight * fitWidthZoom;
}
if (!renderedHeight || renderedHeight <= 0) {
return { type: 'fitWidth' };
}
const isLandscape = aspectRatio !== null && aspectRatio < 1;
const targetVisibility = isLandscape ? 100 : visibilityThreshold;
const visiblePercent = (viewportHeight / renderedHeight) * 100;
if (visiblePercent >= targetVisibility) {
return { type: 'fitWidth' };
}
const allowableHeightRatio = targetVisibility / 100;
const zoomScale =
viewportHeight / (allowableHeightRatio * renderedHeight);
const targetZoom = Math.min(fitWidthZoom, fitWidthZoom * zoomScale);
if (Math.abs(targetZoom - fitWidthZoom) < 0.001) {
return { type: 'fitWidth' };
}
return { type: 'adjust', zoom: targetZoom };
}
export interface MeasurePageRectOptions {
selector?: string;
maxAttempts?: number;
shouldCancel?: () => boolean;
}
export async function measureRenderedPageRect({
selector = '[data-page-index="0"]',
maxAttempts = 12,
shouldCancel,
}: MeasurePageRectOptions = {}): Promise<DOMRect | null> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
let rafId: number | null = null;
const waitForNextFrame = () =>
new Promise<void>((resolve) => {
rafId = window.requestAnimationFrame(() => {
rafId = null;
resolve();
});
});
try {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (shouldCancel?.()) {
return null;
}
const element = document.querySelector(selector) as HTMLElement | null;
if (element) {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
return rect;
}
}
await waitForNextFrame();
}
} finally {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
}
return null;
}
export interface FitWidthResizeOptions {
isManaged: boolean;
requestFitWidth: () => void;
onDebouncedResize: () => void;
debounceMs?: number;
}
export function useFitWidthResize({
isManaged,
requestFitWidth,
onDebouncedResize,
debounceMs = 150,
}: FitWidthResizeOptions): void {
const managedRef = useRef(isManaged);
const requestFitWidthRef = useRef(requestFitWidth);
const onDebouncedResizeRef = useRef(onDebouncedResize);
useEffect(() => {
managedRef.current = isManaged;
}, [isManaged]);
useEffect(() => {
requestFitWidthRef.current = requestFitWidth;
}, [requestFitWidth]);
useEffect(() => {
onDebouncedResizeRef.current = onDebouncedResize;
}, [onDebouncedResize]);
useEffect(() => {
let timeoutId: number | undefined;
const handleResize = () => {
if (!managedRef.current) {
return;
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
requestFitWidthRef.current?.();
onDebouncedResizeRef.current?.();
}, debounceMs);
};
window.addEventListener('resize', handleResize);
return () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
};
}, [debounceMs]);
}