Bug/pageeditor virtualisation (#5614)

This commit is contained in:
Reece Browne 2026-01-31 20:07:45 +00:00 committed by GitHub
parent 789eaa263f
commit d39a7ddda7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 381 additions and 114 deletions

View File

@ -141,14 +141,15 @@ export default function Workbench() {
);
case "pageEditor":
return (
<>
<div style={{ position: 'relative', flex: '1 1 0', height: 0 }}>
<PageEditor
onFunctionsReady={setPageEditorFunctions}
/>
{pageEditorFunctions && (
<PageEditorControls
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 100 }}>
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
@ -168,8 +169,9 @@ export default function Workbench() {
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
</div>
)}
</>
</div>
);
default:
@ -207,9 +209,10 @@ export default function Workbench() {
{/* Main content area */}
<Box
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
className={`flex-1 min-h-0 z-10 ${currentView === 'pageEditor' ? 'relative flex flex-col' : `relative ${styles.workbenchScrollable}`}`}
style={{
transition: 'opacity 0.15s ease-in-out',
...(currentView === 'pageEditor' && { height: 0 }),
}}
>
{renderMainContent()}

View File

@ -37,6 +37,7 @@ interface DragDropGridProps<T extends DragDropItem> {
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
selectedFileIds?: string[];
onVisibleItemsChange?: (items: T[]) => void;
}
type DropSide = 'left' | 'right' | null;
@ -198,7 +199,7 @@ interface DraggableItemProps<T extends DragDropItem> {
zoomLevel: number;
}
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const isPlaceholder = Boolean(item.isPlaceholder);
const pageNumber = (item as any).pageNumber ?? index + 1;
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
@ -252,6 +253,31 @@ const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelec
);
};
// Memoize to prevent unnecessary re-renders and hook thrashing
const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => {
// Return true to SKIP re-render (props are equal)
// Return false to RE-RENDER (props changed)
// Check if item reference or content changed (including thumbnail)
const itemChanged = prevProps.item !== nextProps.item;
// If item object reference changed, we need to re-render
if (itemChanged) {
return false; // Props changed, re-render needed
}
// Item reference is same, check other props
return (
prevProps.item.id === nextProps.item.id &&
prevProps.index === nextProps.index &&
prevProps.activeId === nextProps.activeId &&
prevProps.justMoved === nextProps.justMoved &&
prevProps.zoomLevel === nextProps.zoomLevel &&
prevProps.activeDragIds.length === nextProps.activeDragIds.length &&
prevProps.boxSelectedPageIds.length === nextProps.boxSelectedPageIds.length
);
}) as typeof DraggableItemInner;
const DragDropGrid = <T extends DragDropItem>({
items,
renderItem,
@ -259,6 +285,7 @@ const DragDropGrid = <T extends DragDropItem>({
getThumbnailData,
zoomLevel = 1.0,
selectedFileIds,
onVisibleItemsChange,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
@ -421,6 +448,21 @@ const DragDropGrid = <T extends DragDropItem>({
overscan: OVERSCAN,
});
const virtualRows = rowVirtualizer.getVirtualItems();
useEffect(() => {
if (!onVisibleItemsChange) return;
const visibleItemsForCallback: T[] = [];
virtualRows.forEach((row) => {
const startIndex = row.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
visibleItemsForCallback.push(...visibleItems.slice(startIndex, endIndex));
});
onVisibleItemsChange(visibleItemsForCallback);
}, [virtualRows, visibleItems, itemsPerRow, onVisibleItemsChange]);
// Re-measure virtualizer when zoom or items per row changes
useEffect(() => {
rowVirtualizer.measure();
@ -719,7 +761,7 @@ const DragDropGrid = <T extends DragDropItem>({
margin: '0 auto',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
{virtualRows.map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
const rowItems = visibleItems.slice(startIndex, endIndex);

View File

@ -3,7 +3,7 @@ import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { usePageEditor } from "@app/contexts/PageEditorContext";
import { PageEditorFunctions } from "@app/types/pageEditor";
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
import '@app/components/pageEditor/PageEditor.module.css';
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
@ -23,6 +23,7 @@ import { useUndoManagerState } from "@app/components/pageEditor/hooks/useUndoMan
import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePageSelectionManager";
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands";
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
@ -40,7 +41,27 @@ const PageEditor = ({
const { setHasUnsavedChanges } = useNavigationGuard();
// Get PageEditor coordination functions
const { updateFileOrderFromPages, fileOrder, reorderedPages, clearReorderedPages, updateCurrentPages } = usePageEditor();
const {
updateFileOrderFromPages,
fileOrder,
reorderedPages,
clearReorderedPages,
updateCurrentPages,
savePersistedDocument,
} = usePageEditor();
const [visiblePageIds, setVisiblePageIds] = useState<string[]>([]);
const thumbnailRequestsRef = useRef<Set<string>>(new Set());
const { requestThumbnail, getThumbnailFromCache } = useThumbnailGeneration();
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
setVisiblePageIds(prev => {
const ids = items.map(item => item.id);
if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) {
return prev;
}
return ids;
});
}, []);
// Zoom state management
const [zoomLevel, setZoomLevel] = useState(1.0);
@ -149,6 +170,21 @@ const PageEditor = ({
updateCurrentPages,
});
const displayDocumentRef = useRef(displayDocument);
useEffect(() => {
displayDocumentRef.current = displayDocument;
}, [displayDocument]);
useEffect(() => {
return () => {
const doc = displayDocumentRef.current;
if (doc && doc.pages.length > 0) {
const signature = doc.pages.map(page => page.id).join(',');
savePersistedDocument(doc, signature);
}
};
}, [savePersistedDocument]);
// UI state management
const {
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
@ -231,6 +267,92 @@ const PageEditor = ({
setSplitPositions,
});
useEffect(() => {
if (!displayDocument || visiblePageIds.length === 0) {
return;
}
const pending = thumbnailRequestsRef.current.size;
const MAX_CONCURRENT_THUMBNAILS = 12;
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) {
return;
}
const toLoad: string[] = [];
for (const pageId of visiblePageIds) {
if (toLoad.length >= available) break;
if (thumbnailRequestsRef.current.has(pageId)) continue;
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page || page.thumbnail) continue;
toLoad.push(pageId);
}
if (toLoad.length === 0) return;
toLoad.forEach(pageId => {
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page) return;
const cached = getThumbnailFromCache(pageId);
if (cached) {
thumbnailRequestsRef.current.add(pageId);
Promise.resolve(cached)
.then(cache => {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
// Only create new page object for the changed page, reuse rest
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
return { ...prev, pages: updated };
});
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
return;
}
const fileId = page.originalFileId;
if (!fileId) return;
const file = selectors.getFile(fileId);
if (!file) return;
thumbnailRequestsRef.current.add(pageId);
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
.then(thumbnail => {
if (thumbnail) {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
// Only create new page object for the changed page, reuse rest
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
return { ...prev, pages: updated };
});
}
})
.catch((error) => {
console.error('[Thumbnail Loading] Error:', error);
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
});
}, [
displayDocument,
visiblePageIds,
selectors,
requestThumbnail,
getThumbnailFromCache,
setEditedDocument,
]);
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
const selectedPageCount = selectedPageIds.length;
const activeFileIds = selectedFileIds;
@ -345,12 +467,17 @@ const PageEditor = ({
const fileColorIndexMap = useFileColorMap(orderedFileIds);
return (
<Box
<div
ref={containerRef}
pos="relative"
data-scrolling-container="true"
onMouseEnter={() => setIsContainerHovered(true)}
onMouseLeave={() => setIsContainerHovered(false)}
style={{
height: '100%',
overflow: 'auto',
position: 'relative',
width: '100%',
}}
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
@ -372,7 +499,7 @@ const PageEditor = ({
)}
{displayDocument && (
<Box ref={gridContainerRef} p={0} pt="2rem" pb="15rem" style={{ position: 'relative' }}>
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: 'relative' }}>
{/* Split Lines Overlay */}
<div
@ -450,6 +577,7 @@ const PageEditor = ({
onReorderPages={handleReorderPages}
zoomLevel={zoomLevel}
selectedFileIds={selectedFileIds}
onVisibleItemsChange={handleVisibleItemsChange}
getThumbnailData={(pageId) => {
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page?.thumbnail) return null;
@ -468,7 +596,6 @@ const PageEditor = ({
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
@ -512,7 +639,7 @@ const PageEditor = ({
}}
/>
</Box>
</div>
);
};

View File

@ -9,7 +9,6 @@ import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
import styles from '@app/components/pageEditor/PageEditor.module.css';
@ -22,7 +21,6 @@ interface PageThumbnailProps {
page: PDFPage;
index: number;
totalPages: number;
originalFile?: File;
fileColorIndex: number;
selectedPageIds: string[];
selectionMode: boolean;
@ -55,7 +53,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
page,
index: _index,
totalPages,
originalFile,
fileColorIndex,
selectedPageIds,
selectionMode,
@ -90,7 +87,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const elementRef = useRef<HTMLDivElement | null>(null);
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Check if this page is currently being dragged
@ -115,43 +111,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}
}, [page.thumbnail, thumbnailUrl]);
// Request thumbnail if missing (on-demand, virtualized approach)
useEffect(() => {
let isCancelled = false;
// If we already have a thumbnail, use it
if (page.thumbnail) {
setThumbnailUrl(page.thumbnail);
return;
}
// Check cache first
const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) {
setThumbnailUrl(cachedThumbnail);
return;
}
// Request thumbnail generation if we have the original file
if (originalFile) {
const pageNumber = page.originalPageNumber;
requestThumbnail(page.id, originalFile, pageNumber)
.then(thumbnail => {
if (!isCancelled && thumbnail) {
setThumbnailUrl(thumbnail);
}
})
.catch(error => {
console.warn(`Failed to generate thumbnail for ${page.id}:`, error);
});
}
return () => {
isCancelled = true;
};
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
// Merge refs - combine our ref tracking with dnd-kit's ref
const mergedRef = useCallback((element: HTMLDivElement | null) => {
// Track in our refs map

View File

@ -748,43 +748,49 @@ export class InsertFilesCommand extends DOMCommand {
console.log('Pages:', pages.length);
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (arrayBuffer && arrayBuffer.byteLength > 0) {
// Extract page numbers for all pages from this file
const pageNumbers = pages.map(page => {
const pageNumMatch = page.id.match(/-page-(\d+)$/);
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
});
try {
if (arrayBuffer && arrayBuffer.byteLength > 0) {
// Extract page numbers for all pages from this file
const pageNumbers = pages.map(page => {
const pageNumMatch = page.id.match(/-page-(\d+)$/);
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
});
console.log('Generating thumbnails for page numbers:', pageNumbers);
console.log('Generating thumbnails for page numbers:', pageNumbers);
// Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
pageNumbers,
{ scale: 0.2, quality: 0.8 }
);
// Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
pageNumbers,
{ scale: 0.2, quality: 0.8 }
);
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
// Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) {
const result = results[i];
const page = pages[i];
// Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) {
const result = results[i];
const page = pages[i];
if (result.success) {
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
if (pageIndex >= 0) {
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
console.log('Updated thumbnail for page:', page.id);
if (result.success) {
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
if (pageIndex >= 0) {
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
console.log('Updated thumbnail for page:', page.id);
}
}
}
}
// Trigger re-render by updating the document
this.setDocument({ ...updatedDocument });
} else {
console.error('No valid ArrayBuffer found for file ID:', fileId);
// Trigger re-render by updating the document
this.setDocument({ ...updatedDocument });
} else {
console.error('No valid ArrayBuffer found for file ID:', fileId);
}
} catch (error) {
console.error('Failed to generate thumbnails for file:', fileId, error);
} finally {
this.fileDataMap.delete(fileId);
}
}
} catch (error) {

View File

@ -3,6 +3,6 @@ export const GRID_CONSTANTS = {
ITEM_WIDTH: '20rem', // page width
ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap
ITEM_GAP: '1.5rem', // gap between items
OVERSCAN_SMALL: 4, // Overscan for normal documents
OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages)
OVERSCAN_SMALL: 8, // Overscan for normal documents
OVERSCAN_LARGE: 12, // Overscan for large documents (12 rows = ~96 pages pre-rendered)
} as const;

View File

@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useMemo, useEffect, useState } from 'react';
import { useFileState } from '@app/contexts/FileContext';
import { usePageEditor } from '@app/contexts/PageEditorContext';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { FileId } from '@app/types/file';
import { FileAnalyzer } from '@app/services/fileAnalyzer';
export interface PageDocumentHook {
document: PDFDocument | null;
@ -16,7 +17,7 @@ export interface PageDocumentHook {
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
const { fileOrder, currentPages } = usePageEditor();
const { fileOrder, currentPages, persistedDocument, persistedDocumentSignature } = usePageEditor();
// Use PageEditorContext's fileOrder instead of FileContext's global order
// This ensures the page editor respects its own workspace ordering
@ -58,6 +59,63 @@ export function usePageDocument(): PageDocumentHook {
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
const [placeholderDocument, setPlaceholderDocument] = useState<PDFDocument | null>(null);
useEffect(() => {
if (!primaryFileId) {
setPlaceholderDocument(null);
return;
}
if (primaryStirlingFileStub?.processedFile) {
setPlaceholderDocument(null);
return;
}
const file = selectors.getFile(primaryFileId);
if (!file) {
setPlaceholderDocument(null);
return;
}
let canceled = false;
const loadPlaceholder = async () => {
try {
const analysis = await FileAnalyzer.quickPDFAnalysis(file);
if (canceled) return;
const totalPages = Math.max(1, analysis.pageCount || 1);
const pages: PDFPage[] = Array.from({ length: totalPages }, (_, index) => ({
id: `placeholder-${primaryFileId}-page-${index + 1}`,
pageNumber: index + 1,
thumbnail: null,
rotation: 0,
selected: false,
originalFileId: primaryFileId,
originalPageNumber: index + 1,
}));
setPlaceholderDocument({
id: `placeholder-${primaryFileId}`,
name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
file,
pages,
totalPages,
});
} catch {
if (!canceled) {
setPlaceholderDocument(null);
}
}
};
loadPlaceholder();
return () => {
canceled = true;
};
}, [primaryFileId, primaryStirlingFileStub?.processedFile, selectors]);
// Compute merged document with stable signature (prevents infinite loops)
const currentPagesSignature = useMemo(() => {
return currentPages ? currentPages.map(page => page.id).join(',') : '';
@ -66,7 +124,20 @@ export function usePageDocument(): PageDocumentHook {
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
if (
persistedDocument &&
persistedDocumentSignature &&
persistedDocumentSignature === currentPagesSignature &&
currentPagesSignature.length > 0
) {
return persistedDocument;
}
if (!primaryStirlingFileStub?.processedFile && placeholderDocument) {
return placeholderDocument;
}
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryStirlingFileStub) {
@ -245,7 +316,24 @@ export function usePageDocument(): PageDocumentHook {
};
return mergedDoc;
}, [activeFileIds, selectedActiveFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds, currentPagesSignature, currentPages]);
}, [
activeFileIds,
selectedActiveFileIds,
primaryFileId,
primaryStirlingFileStub,
processedFilePages,
processedFileTotalPages,
selectors,
activeFilesSignature,
selectedFileIdsKey,
state.ui.selectedFileIds,
allFileIds,
currentPagesSignature,
currentPages,
persistedDocument,
persistedDocumentSignature,
placeholderDocument,
]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {

View File

@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo, useRef, useEffect } from 'react';
import { FileId } from '@app/types/file';
import { useFileActions, useFileState } from '@app/contexts/FileContext';
import { PDFPage } from '@app/types/pageEditor';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors';
// PageEditorFile is now defined locally in consuming components
@ -129,6 +129,10 @@ interface PageEditorContextValue {
// Update file order based on page positions (when pages are manually reordered)
updateFileOrderFromPages: (pages: PDFPage[]) => void;
persistedDocument: PDFDocument | null;
persistedDocumentSignature: string | null;
savePersistedDocument: (document: PDFDocument, signature: string) => void;
clearPersistedDocument: () => void;
}
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
@ -141,6 +145,19 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
const [persistedDocument, setPersistedDocument] = useState<PDFDocument | null>(null);
const [persistedDocumentSignature, setPersistedDocumentSignature] = useState<string | null>(null);
const savePersistedDocument = useCallback((document: PDFDocument, signature: string) => {
setPersistedDocument(document);
setPersistedDocumentSignature(signature);
}, []);
const clearPersistedDocument = useCallback(() => {
setPersistedDocument(null);
setPersistedDocumentSignature(null);
}, []);
// Page editor's own file order (independent of FileContext)
const [fileOrder, setFileOrder] = useState<FileId[]>([]);
@ -148,6 +165,20 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
const { actions: fileActions } = useFileActions();
const { state } = useFileState();
const fileContextSignature = useMemo(() => {
return state.files.ids
.map(id => `${id}:${state.files.byId[id]?.versionNumber ?? 0}`)
.join(',');
}, [state.files.ids, state.files.byId]);
const prevFileContextSignature = useRef<string | null>(null);
useEffect(() => {
if (prevFileContextSignature.current !== fileContextSignature) {
prevFileContextSignature.current = fileContextSignature;
clearPersistedDocument();
}
}, [fileContextSignature, clearPersistedDocument]);
// Keep a ref to always read latest state in stable callbacks
const stateRef = useRef(state);
useEffect(() => {
@ -203,7 +234,7 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
}
});
}, 100);
}, [state.files.ids, state.files.byId, fileActions]);
}, [state.files.ids, state.files.byId, fileActions]);
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
setCurrentPages(pages);
@ -329,6 +360,10 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
deselectAll,
reorderFiles,
updateFileOrderFromPages,
persistedDocument,
persistedDocumentSignature,
savePersistedDocument,
clearPersistedDocument,
}), [
currentPages,
updateCurrentPages,
@ -341,6 +376,10 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
deselectAll,
reorderFiles,
updateFileOrderFromPages,
persistedDocument,
persistedDocumentSignature,
savePersistedDocument,
clearPersistedDocument,
]);
return (

View File

@ -20,10 +20,13 @@ let batchTimer: number | null = null;
// Track active thumbnail requests to prevent duplicates across components
const activeRequests = new Map<string, Promise<string | null>>();
// Cache ArrayBuffers to avoid reading the same file multiple times
const fileArrayBufferCache = new Map<File, ArrayBuffer>();
// Batch processing configuration
const BATCH_SIZE = 20; // Process thumbnails in batches of 20 for better UI responsiveness
const BATCH_DELAY = 100; // Wait 100ms to collect requests before processing
const PRIORITY_BATCH_DELAY = 50; // Faster processing for the first batch (visible pages)
const BATCH_SIZE = 10; // Process thumbnails in batches of 10 for faster initial load
const BATCH_DELAY = 50; // Wait 50ms to collect requests before processing
const PRIORITY_BATCH_DELAY = 10; // Very fast processing for the first batch (visible pages)
// Process the queue in batches for better performance
async function processRequestQueue() {
@ -68,8 +71,13 @@ async function processRequestQueue() {
try {
const pageNumbers = requests.map(req => req.pageNumber);
const arrayBuffer = await file.arrayBuffer();
// Get or create cached ArrayBuffer to avoid reading file multiple times
let arrayBuffer = fileArrayBufferCache.get(file);
if (!arrayBuffer) {
arrayBuffer = await file.arrayBuffer();
fileArrayBufferCache.set(file, arrayBuffer);
}
// Use quickKey for PDF document caching (same metadata, consistent format)
const fileId = createQuickKey(file) as FileId;
@ -106,6 +114,10 @@ async function processRequestQueue() {
}
} finally {
isProcessingQueue = false;
// Clean up ArrayBuffer cache when queue is empty
if (requestQueue.length === 0) {
fileArrayBufferCache.clear();
}
}
}
@ -163,6 +175,9 @@ export function useThumbnailGeneration() {
activeRequests.clear();
isProcessingQueue = false;
// Clear ArrayBuffer cache
fileArrayBufferCache.clear();
thumbnailGenerationService.destroy();
}, []);

View File

@ -265,17 +265,13 @@ export class EnhancedPDFProcessingService {
this.notifyListeners();
}
// Create placeholder pages for remaining pages
// Create placeholder pages for remaining pages without touching PDF.js
for (let i = priorityCount + 1; i <= totalPages; i++) {
// Load page just to get rotation
const page = await pdf.getPage(i);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null, // Will be loaded lazily
rotation,
rotation: 0,
selected: false
});
}
@ -337,17 +333,13 @@ export class EnhancedPDFProcessingService {
}
}
// Create placeholders for remaining pages
// Create placeholders for remaining pages without invoking PDF.js
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
// Load page just to get rotation
const page = await pdf.getPage(i);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation,
rotation: 0,
selected: false
});
}
@ -377,15 +369,11 @@ export class EnhancedPDFProcessingService {
// Create placeholder pages without thumbnails
const pages: PDFPage[] = [];
for (let i = 1; i <= totalPages; i++) {
// Load page just to get rotation
const page = await pdf.getPage(i);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation,
rotation: 0,
selected: false
});
}

View File

@ -56,7 +56,7 @@ export class FileAnalyzer {
/**
* Quick PDF analysis without full processing
*/
private static async quickPDFAnalysis(file: File): Promise<{
static async quickPDFAnalysis(file: File): Promise<{
pageCount: number;
isEncrypted: boolean;
isCorrupted: boolean;