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;
}
+120 -438
View File
@@ -1,112 +1,55 @@
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
import React, {
createContext,
useContext,
useState,
ReactNode,
useRef,
useCallback,
} from 'react';
import { useNavigation } from '@app/contexts/NavigationContext';
import {
createViewerActions,
ScrollActions,
ZoomActions,
PanActions,
SelectionActions,
SpreadActions,
RotationActions,
SearchActions,
ExportActions,
} from '@app/contexts/viewer/viewerActions';
import {
BridgeRef,
BridgeApiMap,
BridgeStateMap,
BridgeKey,
ViewerBridgeRegistry,
createBridgeRegistry,
registerBridge as setBridgeRef,
ScrollState,
ZoomState,
PanState,
SelectionState,
SpreadState,
RotationState,
SearchState,
ExportState,
ThumbnailAPIWrapper,
} from '@app/contexts/viewer/viewerBridges';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
// Bridge API interfaces - these match what the bridges provide
interface ScrollAPIWrapper {
scrollToPage: (params: { pageNumber: number }) => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
}
function useImmediateNotifier<Args extends unknown[]>() {
const callbackRef = useRef<((...args: Args) => void) | null>(null);
interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
}
const register = useCallback((callback: (...args: Args) => void) => {
callbackRef.current = callback;
}, []);
interface PanAPIWrapper {
enable: () => void;
disable: () => void;
toggle: () => void;
}
const trigger = useCallback((...args: Args) => {
callbackRef.current?.(...args);
}, []);
interface SelectionAPIWrapper {
copyToClipboard: () => void;
getSelectedText: () => string | any;
getFormattedSelection: () => any;
}
interface SpreadAPIWrapper {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
}
interface RotationAPIWrapper {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
}
interface SearchAPIWrapper {
search: (query: string) => Promise<any>;
clear: () => void;
next: () => void;
previous: () => void;
}
interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
}
interface ExportAPIWrapper {
download: () => void;
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
currentPage: number;
totalPages: number;
}
interface ZoomState {
currentZoom: number;
zoomPercent: number;
}
interface PanState {
isPanning: boolean;
}
interface SelectionState {
hasSelection: boolean;
}
interface SpreadState {
spreadMode: SpreadMode;
isDualPage: boolean;
}
interface RotationState {
rotation: number;
}
interface SearchResult {
pageIndex: number;
rects: Array<{
origin: { x: number; y: number };
size: { width: number; height: number };
}>;
}
interface SearchState {
results: SearchResult[] | null;
activeIndex: number;
}
interface ExportState {
canExport: boolean;
}
// Bridge registration interface - bridges register with state and API
interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
api: TApi;
return { register, trigger };
}
/**
@@ -150,66 +93,28 @@ interface ViewerContextType {
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void;
// Action handlers - call EmbedPDF APIs directly
scrollActions: {
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
};
zoomActions: {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
};
panActions: {
enablePan: () => void;
disablePan: () => void;
togglePan: () => void;
};
selectionActions: {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => unknown;
};
spreadActions: {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
};
rotationActions: {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
};
searchActions: {
search: (query: string) => Promise<void>;
next: () => void;
previous: () => void;
clear: () => void;
};
exportActions: {
download: () => void;
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
scrollActions: ScrollActions;
zoomActions: ZoomActions;
panActions: PanActions;
selectionActions: SelectionActions;
spreadActions: SpreadActions;
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
registerBridge: <K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => void;
}
export const ViewerContext = createContext<ViewerContextType | null>(null);
@@ -229,56 +134,51 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
useNavigation();
// Bridge registry - bridges register their state and APIs here
const bridgeRefs = useRef({
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
zoom: null as BridgeRef<ZoomState, ZoomAPIWrapper> | null,
pan: null as BridgeRef<PanState, PanAPIWrapper> | null,
selection: null as BridgeRef<SelectionState, SelectionAPIWrapper> | null,
search: null as BridgeRef<SearchState, SearchAPIWrapper> | null,
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
});
const bridgeRefs = useRef<ViewerBridgeRegistry>(createBridgeRegistry());
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
const {
register: registerImmediateZoomUpdate,
trigger: triggerImmediateZoomInternal,
} = useImmediateNotifier<[number]>();
const {
register: registerImmediateScrollUpdate,
trigger: triggerImmediateScrollInternal,
} = useImmediateNotifier<[number, number]>();
const {
register: registerImmediateSpreadUpdate,
trigger: triggerImmediateSpreadInternal,
} = useImmediateNotifier<[SpreadMode, boolean]>();
// Immediate scroll callback for responsive display updates
const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null);
const triggerImmediateZoomUpdate = useCallback(
(percent: number) => {
triggerImmediateZoomInternal(percent);
},
[triggerImmediateZoomInternal]
);
const registerBridge = (type: string, ref: BridgeRef) => {
// Type-safe assignment - we know the bridges will provide correct types
switch (type) {
case 'scroll':
bridgeRefs.current.scroll = ref as BridgeRef<ScrollState, ScrollAPIWrapper>;
break;
case 'zoom':
bridgeRefs.current.zoom = ref as BridgeRef<ZoomState, ZoomAPIWrapper>;
break;
case 'pan':
bridgeRefs.current.pan = ref as BridgeRef<PanState, PanAPIWrapper>;
break;
case 'selection':
bridgeRefs.current.selection = ref as BridgeRef<SelectionState, SelectionAPIWrapper>;
break;
case 'search':
bridgeRefs.current.search = ref as BridgeRef<SearchState, SearchAPIWrapper>;
break;
case 'spread':
bridgeRefs.current.spread = ref as BridgeRef<SpreadState, SpreadAPIWrapper>;
break;
case 'rotation':
bridgeRefs.current.rotation = ref as BridgeRef<RotationState, RotationAPIWrapper>;
break;
case 'thumbnail':
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
break;
case 'export':
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
break;
}
};
const triggerImmediateScrollUpdate = useCallback(
(currentPage: number, totalPages: number) => {
triggerImmediateScrollInternal(currentPage, totalPages);
},
[triggerImmediateScrollInternal]
);
const triggerImmediateSpreadUpdate = useCallback(
(mode: SpreadMode, isDualPage: boolean = mode !== SpreadMode.None) => {
triggerImmediateSpreadInternal(mode, isDualPage);
},
[triggerImmediateSpreadInternal]
);
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => {
setBridgeRef(bridgeRefs.current, type, ref);
},
[]
);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
@@ -334,241 +234,21 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
};
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: page });
}
},
scrollToFirstPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: 1 });
}
},
scrollToPreviousPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPreviousPage) {
api.scrollToPreviousPage();
}
},
scrollToNextPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToNextPage) {
api.scrollToNextPage();
}
},
scrollToLastPage: () => {
const scrollState = getScrollState();
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage && scrollState.totalPages > 0) {
api.scrollToPage({ pageNumber: scrollState.totalPages });
}
}
};
const zoomActions = {
zoomIn: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomIn) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomIn();
}
},
zoomOut: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomOut) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomOut();
}
},
toggleMarqueeZoom: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.toggleMarqueeZoom) {
api.toggleMarqueeZoom();
}
},
requestZoom: (level: number) => {
const api = bridgeRefs.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level);
}
}
};
const panActions = {
enablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.enable) {
api.enable();
}
},
disablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.disable) {
api.disable();
}
},
togglePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.toggle) {
api.toggle();
}
}
};
const selectionActions = {
copyToClipboard: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.copyToClipboard) {
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText();
}
return '';
},
getFormattedSelection: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getFormattedSelection) {
return api.getFormattedSelection();
}
return null;
}
};
const spreadActions = {
setSpreadMode: (mode: SpreadMode) => {
const api = bridgeRefs.current.spread?.api;
if (api?.setSpreadMode) {
api.setSpreadMode(mode);
}
},
getSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.getSpreadMode) {
return api.getSpreadMode();
}
return null;
},
toggleSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.toggleSpreadMode) {
api.toggleSpreadMode();
}
}
};
const rotationActions = {
rotateForward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateForward) {
api.rotateForward();
}
},
rotateBackward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateBackward) {
api.rotateBackward();
}
},
setRotation: (rotation: number) => {
const api = bridgeRefs.current.rotation?.api;
if (api?.setRotation) {
api.setRotation(rotation);
}
},
getRotation: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.getRotation) {
return api.getRotation();
}
return 0;
}
};
const searchActions = {
search: async (query: string) => {
const api = bridgeRefs.current.search?.api;
if (api?.search) {
return api.search(query);
}
},
next: () => {
const api = bridgeRefs.current.search?.api;
if (api?.next) {
api.next();
}
},
previous: () => {
const api = bridgeRefs.current.search?.api;
if (api?.previous) {
api.previous();
}
},
clear: () => {
const api = bridgeRefs.current.search?.api;
if (api?.clear) {
api.clear();
}
}
};
const exportActions = {
download: () => {
const api = bridgeRefs.current.export?.api;
if (api?.download) {
api.download();
}
},
saveAsCopy: async () => {
const api = bridgeRefs.current.export?.api;
if (api?.saveAsCopy) {
try {
const result = api.saveAsCopy();
return await result.toPromise();
} catch (error) {
console.error('Failed to save PDF copy:', error);
return null;
}
}
return null;
}
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => {
immediateScrollUpdateCallback.current = callback;
};
const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => {
if (immediateScrollUpdateCallback.current) {
immediateScrollUpdateCallback.current(currentPage, totalPages);
}
};
const triggerImmediateZoomUpdate = (zoomPercent: number) => {
if (immediateZoomUpdateCallback.current) {
immediateZoomUpdateCallback.current(zoomPercent);
}
};
const {
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
getZoomState,
triggerImmediateZoomUpdate,
});
const value: ViewerContextType = {
// UI state
@@ -600,8 +280,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// Immediate updates
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
triggerImmediateSpreadUpdate,
// Actions
scrollActions,
+11 -3
View File
@@ -58,14 +58,21 @@ const addFilesMutex = new SimpleMutex();
/**
* Helper to create ProcessedFile metadata structure
*/
export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) {
export function createProcessedFile(
pageCount: number,
thumbnail?: string,
pageRotations?: number[],
pageDimensions?: Array<{ width: number; height: number }>
) {
return {
totalPages: pageCount,
pages: Array.from({ length: pageCount }, (_, index) => ({
pageNumber: index + 1,
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
rotation: pageRotations?.[index] ?? 0,
splitBefore: false
splitBefore: false,
width: pageDimensions?.[index]?.width,
height: pageDimensions?.[index]?.height
})),
thumbnailUrl: thumbnail,
lastProcessed: Date.now()
@@ -92,7 +99,8 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
const processedFile = createProcessedFile(
unrotatedResult.pageCount,
unrotatedResult.thumbnail, // Page thumbnails (unrotated)
unrotatedResult.pageRotations
unrotatedResult.pageRotations,
unrotatedResult.pageDimensions
);
// Use rotated thumbnail for file manager
@@ -0,0 +1,311 @@
import { MutableRefObject } from 'react';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
import {
ViewerBridgeRegistry,
ScrollState,
ZoomState,
} from '@app/contexts/viewer/viewerBridges';
export interface ScrollActions {
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
}
export interface ZoomActions {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
}
export interface PanActions {
enablePan: () => void;
disablePan: () => void;
togglePan: () => void;
}
export interface SelectionActions {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => any;
}
export interface SpreadActions {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
}
export interface RotationActions {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
}
export interface SearchActions {
search: (query: string) => Promise<any> | undefined;
next: () => void;
previous: () => void;
clear: () => void;
}
export interface ExportActions {
download: () => void;
saveAsCopy: () => Promise<ArrayBuffer | null>;
}
export interface ViewerActionsBundle {
scrollActions: ScrollActions;
zoomActions: ZoomActions;
panActions: PanActions;
selectionActions: SelectionActions;
spreadActions: SpreadActions;
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
}
interface ViewerActionDependencies {
registry: MutableRefObject<ViewerBridgeRegistry>;
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
triggerImmediateZoomUpdate: (percent: number) => void;
}
export function createViewerActions({
registry,
getScrollState,
getZoomState,
triggerImmediateZoomUpdate,
}: ViewerActionDependencies): ViewerActionsBundle {
const scrollActions: ScrollActions = {
scrollToPage: (page: number) => {
const api = registry.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: page });
}
},
scrollToFirstPage: () => {
const api = registry.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: 1 });
}
},
scrollToPreviousPage: () => {
const api = registry.current.scroll?.api;
if (api?.scrollToPreviousPage) {
api.scrollToPreviousPage();
}
},
scrollToNextPage: () => {
const api = registry.current.scroll?.api;
if (api?.scrollToNextPage) {
api.scrollToNextPage();
}
},
scrollToLastPage: () => {
const api = registry.current.scroll?.api;
const state = getScrollState();
if (api?.scrollToPage && state.totalPages > 0) {
api.scrollToPage({ pageNumber: state.totalPages });
}
},
};
const zoomActions: ZoomActions = {
zoomIn: () => {
const api = registry.current.zoom?.api;
if (api?.zoomIn) {
const currentState = getZoomState();
const newPercent = Math.min(
Math.round(currentState.zoomPercent * 1.2),
300
);
triggerImmediateZoomUpdate(newPercent);
api.zoomIn();
}
},
zoomOut: () => {
const api = registry.current.zoom?.api;
if (api?.zoomOut) {
const currentState = getZoomState();
const newPercent = Math.max(
Math.round(currentState.zoomPercent / 1.2),
20
);
triggerImmediateZoomUpdate(newPercent);
api.zoomOut();
}
},
toggleMarqueeZoom: () => {
const api = registry.current.zoom?.api;
if (api?.toggleMarqueeZoom) {
api.toggleMarqueeZoom();
}
},
requestZoom: (level: number) => {
const api = registry.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level);
}
},
};
const panActions: PanActions = {
enablePan: () => {
const api = registry.current.pan?.api;
if (api?.enable) {
api.enable();
}
},
disablePan: () => {
const api = registry.current.pan?.api;
if (api?.disable) {
api.disable();
}
},
togglePan: () => {
const api = registry.current.pan?.api;
if (api?.toggle) {
api.toggle();
}
},
};
const selectionActions: SelectionActions = {
copyToClipboard: () => {
const api = registry.current.selection?.api;
if (api?.copyToClipboard) {
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = registry.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText() ?? '';
}
return '';
},
getFormattedSelection: () => {
const api = registry.current.selection?.api;
if (api?.getFormattedSelection) {
return api.getFormattedSelection();
}
return null;
},
};
const spreadActions: SpreadActions = {
setSpreadMode: (mode: SpreadMode) => {
const api = registry.current.spread?.api;
if (api?.setSpreadMode) {
api.setSpreadMode(mode);
}
},
getSpreadMode: () => {
const api = registry.current.spread?.api;
if (api?.getSpreadMode) {
return api.getSpreadMode();
}
return null;
},
toggleSpreadMode: () => {
const api = registry.current.spread?.api;
if (api?.toggleSpreadMode) {
api.toggleSpreadMode();
}
},
};
const rotationActions: RotationActions = {
rotateForward: () => {
const api = registry.current.rotation?.api;
if (api?.rotateForward) {
api.rotateForward();
}
},
rotateBackward: () => {
const api = registry.current.rotation?.api;
if (api?.rotateBackward) {
api.rotateBackward();
}
},
setRotation: (rotation: number) => {
const api = registry.current.rotation?.api;
if (api?.setRotation) {
api.setRotation(rotation);
}
},
getRotation: () => {
const api = registry.current.rotation?.api;
if (api?.getRotation) {
return api.getRotation();
}
return 0;
},
};
const searchActions: SearchActions = {
search: (query: string) => {
const api = registry.current.search?.api;
if (api?.search) {
return api.search(query);
}
},
next: () => {
const api = registry.current.search?.api;
if (api?.next) {
api.next();
}
},
previous: () => {
const api = registry.current.search?.api;
if (api?.previous) {
api.previous();
}
},
clear: () => {
const api = registry.current.search?.api;
if (api?.clear) {
api.clear();
}
},
};
const exportActions: ExportActions = {
download: () => {
const api = registry.current.export?.api;
if (api?.download) {
api.download();
}
},
saveAsCopy: async () => {
const api = registry.current.export?.api;
if (api?.saveAsCopy) {
try {
const result = api.saveAsCopy();
return await result.toPromise();
} catch (error) {
console.error('Failed to save PDF copy:', error);
return null;
}
}
return null;
},
};
return {
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
};
}
@@ -0,0 +1,174 @@
import { SpreadMode } from '@embedpdf/plugin-spread/react';
export interface ScrollAPIWrapper {
scrollToPage: (params: { pageNumber: number }) => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
}
export interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
}
export interface PanAPIWrapper {
enable: () => void;
disable: () => void;
toggle: () => void;
makePanDefault: () => void;
}
export interface SelectionAPIWrapper {
copyToClipboard: () => void;
getSelectedText: () => string | any;
getFormattedSelection: () => any;
}
export interface SpreadAPIWrapper {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
SpreadMode: typeof SpreadMode;
}
export interface RotationAPIWrapper {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
}
export interface SearchAPIWrapper {
search: (query: string) => Promise<any>;
clear: () => void;
next: () => void;
previous: () => void;
goToResult: (index: number) => void;
}
export interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => {
toPromise: () => Promise<Blob>;
};
}
export interface ExportAPIWrapper {
download: () => void;
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
export interface ScrollState {
currentPage: number;
totalPages: number;
}
export interface ZoomState {
currentZoom: number;
zoomPercent: number;
}
export interface PanState {
isPanning: boolean;
}
export interface SelectionState {
hasSelection: boolean;
}
export interface SpreadState {
spreadMode: SpreadMode;
isDualPage: boolean;
}
export interface RotationState {
rotation: number;
}
export interface SearchResult {
pageIndex: number;
rects: Array<{
origin: { x: number; y: number };
size: { width: number; height: number };
}>;
}
export interface SearchState {
results: SearchResult[] | null;
activeIndex: number;
}
export interface ExportState {
canExport: boolean;
}
export interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
api: TApi;
}
export interface BridgeStateMap {
scroll: ScrollState;
zoom: ZoomState;
pan: PanState;
selection: SelectionState;
spread: SpreadState;
rotation: RotationState;
search: SearchState;
thumbnail: unknown;
export: ExportState;
}
export interface BridgeApiMap {
scroll: ScrollAPIWrapper;
zoom: ZoomAPIWrapper;
pan: PanAPIWrapper;
selection: SelectionAPIWrapper;
spread: SpreadAPIWrapper;
rotation: RotationAPIWrapper;
search: SearchAPIWrapper;
thumbnail: ThumbnailAPIWrapper;
export: ExportAPIWrapper;
}
export type BridgeKey = keyof BridgeStateMap;
export type ViewerBridgeRegistry = {
[K in BridgeKey]: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null;
};
export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
scroll: null,
zoom: null,
pan: null,
selection: null,
spread: null,
rotation: null,
search: null,
thumbnail: null,
export: null,
});
export function registerBridge<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
): void {
registry[type] = ref as ViewerBridgeRegistry[K];
}
export function getBridgeState<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K,
fallback: BridgeStateMap[K]
): BridgeStateMap[K] {
return registry[type]?.state ?? fallback;
}
export function getBridgeApi<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K
): BridgeApiMap[K] | null {
return registry[type]?.api ?? null;
}
+2
View File
@@ -14,6 +14,8 @@ export interface ProcessedFilePage {
pageNumber?: number;
rotation?: number;
splitBefore?: boolean;
width?: number;
height?: number;
[key: string]: any;
}
+53
View File
@@ -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);
}
+15 -1
View File
@@ -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") {
+188
View File
@@ -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]);
}