Feature/viewer annotation toggle (#4557)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
This commit is contained in:
Reece Browne 2025-10-02 10:40:18 +01:00 committed by GitHub
parent 510e1c38eb
commit 989eea9e24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 307 additions and 37 deletions

View File

@ -192,6 +192,11 @@ return useToolOperation({
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21

View File

@ -3079,7 +3079,12 @@
"panMode": "Pan Mode",
"rotateLeft": "Rotate Left",
"rotateRight": "Rotate Right",
"toggleSidebar": "Toggle Sidebar"
"toggleSidebar": "Toggle Sidebar",
"exportSelected": "Export Selected Pages",
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
},
"search": {
"title": "Search PDF",

View File

@ -8,7 +8,7 @@ interface NavigationWarningModalProps {
}
const NavigationWarningModal = ({
onApplyAndContinue,
onApplyAndContinue: _onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
@ -30,13 +30,6 @@ const NavigationWarningModal = ({
confirmNavigation();
};
const _handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
@ -85,7 +78,7 @@ const NavigationWarningModal = ({
</Button>
{/* TODO:: Add this back in when it works */}
{/* {onApplyAndContinue && (
{/* {_onApplyAndContinue && (
<Button
variant="light"
color="blue"

View File

@ -15,6 +15,7 @@ import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
import { ViewerContext } from '../../contexts/ViewerContext';
import { useSignature } from '../../contexts/SignatureContext';
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
@ -293,6 +294,9 @@ export default function RightRail() {
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls currentView={currentView} />
</div>
<Divider className="right-rail-divider" />
</div>

View File

@ -67,7 +67,7 @@
}
.right-rail-slot.visible {
max-height: 18rem; /* increased to fit additional controls + divider */
max-height: 40rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@ -77,14 +77,14 @@
opacity: 0;
}
100% {
max-height: 18rem;
max-height: 40rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 18rem;
max-height: 40rem;
opacity: 1;
}
100% {

View File

@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '../LocalIcon';
import { Tooltip } from '../Tooltip';
import { ViewerContext } from '../../../contexts/ViewerContext';
import { useSignature } from '../../../contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
interface ViewerAnnotationControlsProps {
currentView: string;
}
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={currentView !== 'viewer'}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={currentView !== 'viewer'}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}

View File

@ -29,16 +29,19 @@ const EmbedPdfViewerContent = ({
const { colorScheme: _colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer();
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer();
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
// Check if we're in signature mode
// Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState();
const isSignatureMode = selectedTool === 'sign';
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
@ -186,7 +189,7 @@ const EmbedPdfViewerContent = ({
<LocalEmbedPDF
file={effectiveFile.file}
url={effectiveFile.url}
enableSignature={isSignatureMode}
enableAnnotations={shouldEnableAnnotations}
signatureApiRef={signatureApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => {

View File

@ -42,13 +42,13 @@ import { ExportAPIBridge } from './ExportAPIBridge';
interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
enableSignature?: boolean;
enableAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
}
export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@ -93,10 +93,10 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
createPluginRegistration(SelectionPluginPackage),
// Register history plugin for undo/redo (recommended for annotations)
...(enableSignature ? [createPluginRegistration(HistoryPluginPackage)] : []),
...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
// Register annotation plugin (depends on InteractionManager, Selection, History)
...(enableSignature ? [createPluginRegistration(AnnotationPluginPackage, {
...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: 'Digital Signature',
autoCommit: true,
deactivateToolAfterCreate: false,
@ -194,7 +194,7 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
<EmbedPDF
engine={engine}
plugins={plugins}
onInitialized={enableSignature ? async (registry) => {
onInitialized={enableAnnotations ? async (registry) => {
const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return;
@ -265,8 +265,8 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableSignature && <SignatureAPIBridge ref={signatureApiRef} />}
{enableSignature && <HistoryAPIBridge ref={historyApiRef} />}
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<GlobalPointerProvider>
<Viewport
@ -312,7 +312,7 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableSignature && (
{enableAnnotations && (
<AnnotationLayer
pageIndex={pageIndex}
scale={scale}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react';
@ -312,4 +312,4 @@ export function LocalEmbedPDFWithAnnotations({
</EmbedPDF>
</div>
);
}
}

View File

@ -3,6 +3,7 @@ import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
import { useSignature } from '../../contexts/SignatureContext';
import { useViewer } from '../../contexts/ViewerContext';
export interface SignatureAPI {
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
@ -20,11 +21,12 @@ export interface SignatureAPI {
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
const { isAnnotationMode } = useViewer();
// Enable keyboard deletion of selected annotations - only when in signature placement mode
// Enable keyboard deletion of selected annotations - when in signature placement mode or viewer annotation mode
useEffect(() => {
if (!annotationApi || !isPlacementMode) return;
if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
@ -65,7 +67,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [annotationApi, storeImageData, isPlacementMode]);
}, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]);
useImperativeHandle(ref, () => ({
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {

View File

@ -15,7 +15,19 @@ export function ZoomAPIBridge() {
if (zoom && !hasSetInitialZoom.current) {
hasSetInitialZoom.current = true;
setTimeout(() => {
zoom.requestZoom(1.4);
try {
zoom.requestZoom(1.4);
} catch (error) {
console.log('Zoom initialization delayed, viewport not ready:', error);
// Retry after a longer delay
setTimeout(() => {
try {
zoom.requestZoom(1.4);
} catch (retryError) {
console.log('Zoom initialization failed:', retryError);
}
}, 200);
}
}, 50);
}
}, [zoom]);

View File

@ -123,6 +123,15 @@ interface ViewerContextType {
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
// Annotation visibility toggle
isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void;
// Annotation/drawing mode for viewer
isAnnotationMode: boolean;
setAnnotationMode: (enabled: boolean) => void;
toggleAnnotationMode: () => void;
// State getters - read current state from bridges
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
@ -208,6 +217,8 @@ interface ViewerProviderProps {
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
// Get current navigation state to check if we're in sign mode
useNavigation();
@ -268,6 +279,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
const setAnnotationMode = (enabled: boolean) => {
setIsAnnotationModeState(enabled);
};
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
};
// State getters - read from bridge refs
const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
@ -547,6 +570,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,
isAnnotationMode,
setAnnotationMode,
toggleAnnotationMode,
// State getters
getScrollState,
getZoomState,

View File

@ -476,7 +476,6 @@ export async function addStirlingFileStubs(
await addFilesMutex.lock();
try {
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
const validStubs: StirlingFileStub[] = [];
@ -515,14 +514,12 @@ export async function addStirlingFileStubs(
record.processedFile.totalPages !== record.processedFile.pages.length;
if (needsProcessing) {
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
// Use centralized metadata generation function
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
if (processedFileMetadata) {
record.processedFile = processedFileMetadata;
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
} else {
// Fallback for files that couldn't be processed
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
@ -541,7 +538,6 @@ export async function addStirlingFileStubs(
// Dispatch ADD_FILES action if we have new files
if (validStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
}
return loadedFiles;

View File

@ -70,7 +70,6 @@ async function processRequestQueue() {
const pageNumbers = requests.map(req => req.pageNumber);
const arrayBuffer = await file.arrayBuffer();
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
// Use quickKey for PDF document caching (same metadata, consistent format)
const fileId = createQuickKey(file) as FileId;
@ -80,9 +79,8 @@ async function processRequestQueue() {
arrayBuffer,
pageNumbers,
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
(progress) => {
(_progress) => {
// Optional: Could emit progress events here for UI feedback
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
}
);