mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
James Brunton
parent
3cf89b6ede
commit
ce6b2460d8
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ProcessedFileMetadata,
|
||||
ProcessedFilePage,
|
||||
StirlingFileStub,
|
||||
} from '@app/types/fileContext';
|
||||
|
||||
export interface PageDimensions {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export function getPageDimensions(
|
||||
page?: ProcessedFilePage | null
|
||||
): PageDimensions {
|
||||
const width =
|
||||
typeof page?.width === 'number' && page.width > 0 ? page.width : null;
|
||||
const height =
|
||||
typeof page?.height === 'number' && page.height > 0 ? page.height : null;
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getFirstPageDimensionsFromMetadata(
|
||||
metadata?: ProcessedFileMetadata | null
|
||||
): PageDimensions {
|
||||
if (!metadata?.pages?.length) {
|
||||
return { width: null, height: null };
|
||||
}
|
||||
|
||||
return getPageDimensions(metadata.pages[0]);
|
||||
}
|
||||
|
||||
export function getFirstPageDimensionsFromStub(
|
||||
file?: StirlingFileStub
|
||||
): PageDimensions {
|
||||
return getFirstPageDimensionsFromMetadata(file?.processedFile);
|
||||
}
|
||||
|
||||
export function getFirstPageAspectRatioFromMetadata(
|
||||
metadata?: ProcessedFileMetadata | null
|
||||
): number | null {
|
||||
const { width, height } = getFirstPageDimensionsFromMetadata(metadata);
|
||||
if (width && height) {
|
||||
return height / width;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFirstPageAspectRatioFromStub(
|
||||
file?: StirlingFileStub
|
||||
): number | null {
|
||||
return getFirstPageAspectRatioFromMetadata(file?.processedFile);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface ThumbnailWithMetadata {
|
||||
thumbnail: string; // Always returns a thumbnail (placeholder if needed)
|
||||
pageCount: number;
|
||||
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
|
||||
pageDimensions?: Array<{ width: number; height: number }>;
|
||||
}
|
||||
|
||||
interface ColorScheme {
|
||||
@@ -402,12 +403,18 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const page = await pdf.getPage(1);
|
||||
const pageDimensions: Array<{ width: number; height: number }> = [];
|
||||
|
||||
// If applyRotation is false, render without rotation (for CSS-based rotation)
|
||||
// If applyRotation is true, let PDF.js apply rotation (for static display)
|
||||
const viewport = applyRotation
|
||||
? page.getViewport({ scale })
|
||||
: page.getViewport({ scale, rotation: 0 });
|
||||
const baseViewport = page.getViewport({ scale: 1, rotation: 0 });
|
||||
pageDimensions[0] = {
|
||||
width: baseViewport.width,
|
||||
height: baseViewport.height
|
||||
};
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
@@ -428,10 +435,17 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
|
||||
const p = await pdf.getPage(i);
|
||||
const rotation = p.rotate || 0;
|
||||
pageRotations.push(rotation);
|
||||
if (!pageDimensions[i - 1]) {
|
||||
const pageViewport = p.getViewport({ scale: 1, rotation: 0 });
|
||||
pageDimensions[i - 1] = {
|
||||
width: pageViewport.width,
|
||||
height: pageViewport.height
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return { thumbnail, pageCount, pageRotations };
|
||||
return { thumbnail, pageCount, pageRotations, pageDimensions };
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "PasswordException") {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user