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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user