mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
Bug/pageeditor virtualisation (#5614)
This commit is contained in:
parent
789eaa263f
commit
d39a7ddda7
@ -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()}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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();
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user