From d39a7ddda73c49c384cd2dfcb31def775e3b6562 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:07:45 +0000 Subject: [PATCH] Bug/pageeditor virtualisation (#5614) --- .../src/core/components/layout/Workbench.tsx | 13 +- .../components/pageEditor/DragDropGrid.tsx | 46 +++++- .../core/components/pageEditor/PageEditor.tsx | 141 +++++++++++++++++- .../components/pageEditor/PageThumbnail.tsx | 41 ----- .../pageEditor/commands/pageCommands.ts | 64 ++++---- .../core/components/pageEditor/constants.ts | 4 +- .../pageEditor/hooks/usePageDocument.ts | 96 +++++++++++- .../src/core/contexts/PageEditorContext.tsx | 43 +++++- .../src/core/hooks/useThumbnailGeneration.ts | 23 ++- .../services/enhancedPDFProcessingService.ts | 22 +-- frontend/src/core/services/fileAnalyzer.ts | 2 +- 11 files changed, 381 insertions(+), 114 deletions(-) diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index a0be189f9..99d65e83f 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -141,14 +141,15 @@ export default function Workbench() { ); case "pageEditor": - + return ( - <> +
{pageEditorFunctions && ( - + +
)} - + ); default: @@ -207,9 +209,10 @@ export default function Workbench() { {/* Main content area */} {renderMainContent()} diff --git a/frontend/src/core/components/pageEditor/DragDropGrid.tsx b/frontend/src/core/components/pageEditor/DragDropGrid.tsx index 32e76ec7e..8f8598c29 100644 --- a/frontend/src/core/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/core/components/pageEditor/DragDropGrid.tsx @@ -37,6 +37,7 @@ interface DragDropGridProps { 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 { zoomLevel: number; } -const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { +const DraggableItemInner = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { 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 = ({ 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 = ({ items, renderItem, @@ -259,6 +285,7 @@ const DragDropGrid = ({ getThumbnailData, zoomLevel = 1.0, selectedFileIds, + onVisibleItemsChange, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); const containerRef = useRef(null); @@ -421,6 +448,21 @@ const DragDropGrid = ({ 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 = ({ 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); diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index b68cdac3c..6c8f554ec 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -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([]); + const thumbnailRequestsRef = useRef>(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 ( - setIsContainerHovered(true)} onMouseLeave={() => setIsContainerHovered(false)} + style={{ + height: '100%', + overflow: 'auto', + position: 'relative', + width: '100%', + }} > @@ -372,7 +499,7 @@ const PageEditor = ({ )} {displayDocument && ( - + {/* Split Lines Overlay */}
{ 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 = ({ }} /> - +
); }; diff --git a/frontend/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/src/core/components/pageEditor/PageThumbnail.tsx index 2cc7d38fa..851b0a8d5 100644 --- a/frontend/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/PageThumbnail.tsx @@ -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 = ({ page, index: _index, totalPages, - originalFile, fileColorIndex, selectedPageIds, selectionMode, @@ -90,7 +87,6 @@ const PageThumbnail: React.FC = ({ const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const elementRef = useRef(null); - const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration(); const { openFilesModal } = useFilesModalContext(); // Check if this page is currently being dragged @@ -115,43 +111,6 @@ const PageThumbnail: React.FC = ({ } }, [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 diff --git a/frontend/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/src/core/components/pageEditor/commands/pageCommands.ts index 065a18ee8..536f1d667 100644 --- a/frontend/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/core/components/pageEditor/commands/pageCommands.ts @@ -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) { diff --git a/frontend/src/core/components/pageEditor/constants.ts b/frontend/src/core/components/pageEditor/constants.ts index 13239d722..28ee92580 100644 --- a/frontend/src/core/components/pageEditor/constants.ts +++ b/frontend/src/core/components/pageEditor/constants.ts @@ -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; \ No newline at end of file diff --git a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts index 40b823bd1..85ee311d6 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts @@ -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(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(() => { diff --git a/frontend/src/core/contexts/PageEditorContext.tsx b/frontend/src/core/contexts/PageEditorContext.tsx index 6881db1ed..2acee3ec2 100644 --- a/frontend/src/core/contexts/PageEditorContext.tsx +++ b/frontend/src/core/contexts/PageEditorContext.tsx @@ -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(undefined); @@ -141,6 +145,19 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { const [currentPages, setCurrentPages] = useState(null); const [reorderedPages, setReorderedPages] = useState(null); + const [persistedDocument, setPersistedDocument] = useState(null); + const [persistedDocumentSignature, setPersistedDocumentSignature] = useState(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([]); @@ -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(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 ( diff --git a/frontend/src/core/hooks/useThumbnailGeneration.ts b/frontend/src/core/hooks/useThumbnailGeneration.ts index 48f7b241b..a2b251786 100644 --- a/frontend/src/core/hooks/useThumbnailGeneration.ts +++ b/frontend/src/core/hooks/useThumbnailGeneration.ts @@ -20,10 +20,13 @@ let batchTimer: number | null = null; // Track active thumbnail requests to prevent duplicates across components const activeRequests = new Map>(); +// Cache ArrayBuffers to avoid reading the same file multiple times +const fileArrayBufferCache = new Map(); + // 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(); }, []); diff --git a/frontend/src/core/services/enhancedPDFProcessingService.ts b/frontend/src/core/services/enhancedPDFProcessingService.ts index b084d2eb1..17aeb015a 100644 --- a/frontend/src/core/services/enhancedPDFProcessingService.ts +++ b/frontend/src/core/services/enhancedPDFProcessingService.ts @@ -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 }); } diff --git a/frontend/src/core/services/fileAnalyzer.ts b/frontend/src/core/services/fileAnalyzer.ts index e168e530b..7181489d5 100644 --- a/frontend/src/core/services/fileAnalyzer.ts +++ b/frontend/src/core/services/fileAnalyzer.ts @@ -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;