mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Fix and improve pageeditor
This commit is contained in:
parent
25a721e71e
commit
1730402eff
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
|
|
||||||
@ -47,6 +47,100 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
dragPosition,
|
dragPosition,
|
||||||
}: DragDropGridProps<T>) => {
|
}: DragDropGridProps<T>) => {
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
|
||||||
|
// Virtualization configuration - adjust for document size
|
||||||
|
const isLargeDocument = items.length > 1000; // Only virtualize for very large documents
|
||||||
|
const ITEM_HEIGHT = 340; // Height of PageThumbnail + gap (20rem + gap)
|
||||||
|
const ITEMS_PER_ROW = 4; // Approximate items per row
|
||||||
|
const BUFFER_SIZE = isLargeDocument ? 2 : 3; // Larger buffer for smoother scrolling
|
||||||
|
const OVERSCAN = ITEMS_PER_ROW * BUFFER_SIZE; // Total buffer items
|
||||||
|
|
||||||
|
// Log virtualization stats for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (items.length > 100) {
|
||||||
|
console.log(`📊 DragDropGrid: Virtualizing ${items.length} items (large doc: ${isLargeDocument}, buffer: ${BUFFER_SIZE})`);
|
||||||
|
}
|
||||||
|
}, [items.length, isLargeDocument, BUFFER_SIZE]);
|
||||||
|
|
||||||
|
// Throttled scroll handler to prevent excessive re-renders
|
||||||
|
const throttleRef = useRef<number>();
|
||||||
|
|
||||||
|
// Detect scroll position from parent container
|
||||||
|
useEffect(() => {
|
||||||
|
const updateScrollPosition = () => {
|
||||||
|
// Throttle scroll updates for better performance
|
||||||
|
if (throttleRef.current) {
|
||||||
|
cancelAnimationFrame(throttleRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
throttleRef.current = requestAnimationFrame(() => {
|
||||||
|
const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') ||
|
||||||
|
containerRef.current?.offsetParent?.closest('div[style*="overflow"]');
|
||||||
|
|
||||||
|
if (scrollingParent) {
|
||||||
|
setScrollTop(scrollingParent.scrollTop || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') ||
|
||||||
|
containerRef.current?.offsetParent?.closest('div[style*="overflow"]');
|
||||||
|
|
||||||
|
if (scrollingParent) {
|
||||||
|
// Use passive listener for better scrolling performance
|
||||||
|
scrollingParent.addEventListener('scroll', updateScrollPosition, { passive: true });
|
||||||
|
updateScrollPosition(); // Initial position
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollingParent.removeEventListener('scroll', updateScrollPosition);
|
||||||
|
if (throttleRef.current) {
|
||||||
|
cancelAnimationFrame(throttleRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate visible range with virtualization (only for very large documents)
|
||||||
|
const { startIndex, endIndex, totalHeight, topSpacer } = useMemo(() => {
|
||||||
|
// Skip virtualization for smaller documents to avoid jankiness
|
||||||
|
if (!isLargeDocument) {
|
||||||
|
return {
|
||||||
|
startIndex: 0,
|
||||||
|
endIndex: items.length,
|
||||||
|
totalHeight: Math.ceil(items.length / ITEMS_PER_ROW) * ITEM_HEIGHT,
|
||||||
|
topSpacer: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerHeight = containerRef.current?.clientHeight || 600;
|
||||||
|
const rowHeight = ITEM_HEIGHT;
|
||||||
|
const totalRows = Math.ceil(items.length / ITEMS_PER_ROW);
|
||||||
|
const visibleRows = Math.ceil(containerHeight / rowHeight);
|
||||||
|
|
||||||
|
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_SIZE);
|
||||||
|
const endRow = Math.min(totalRows, startRow + visibleRows + BUFFER_SIZE * 2);
|
||||||
|
|
||||||
|
const startIndex = startRow * ITEMS_PER_ROW;
|
||||||
|
const endIndex = Math.min(items.length, endRow * ITEMS_PER_ROW);
|
||||||
|
const totalHeight = totalRows * rowHeight;
|
||||||
|
const topSpacer = startRow * rowHeight;
|
||||||
|
|
||||||
|
return { startIndex, endIndex, totalHeight, topSpacer };
|
||||||
|
}, [items.length, scrollTop, ITEM_HEIGHT, ITEMS_PER_ROW, BUFFER_SIZE, isLargeDocument]);
|
||||||
|
|
||||||
|
// Only render visible items for performance
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
const visible = items.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Debug logging for large documents
|
||||||
|
if (items.length > 500 && visible.length > 0) {
|
||||||
|
console.log(`📊 DragDropGrid: Rendering ${visible.length} items (${startIndex}-${endIndex-1}) of ${items.length} total`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}, [items, startIndex, endIndex]);
|
||||||
|
|
||||||
// Global drag cleanup
|
// Global drag cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -70,49 +164,68 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}, [draggedItem, onDragEnd]);
|
}, [draggedItem, onDragEnd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
// Performance optimizations for smooth scrolling
|
||||||
|
transform: 'translateZ(0)', // Force hardware acceleration
|
||||||
|
backfaceVisibility: 'hidden', // Better rendering performance
|
||||||
|
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
position: 'relative',
|
||||||
flexWrap: 'wrap',
|
height: totalHeight,
|
||||||
gap: '1.5rem',
|
paddingBottom: '100px'
|
||||||
justifyContent: 'flex-start',
|
|
||||||
paddingBottom: '100px',
|
|
||||||
// Performance optimizations for smooth scrolling
|
|
||||||
willChange: 'scroll-position',
|
|
||||||
transform: 'translateZ(0)', // Force hardware acceleration
|
|
||||||
backfaceVisibility: 'hidden',
|
|
||||||
// Use containment for better rendering performance
|
|
||||||
contain: 'layout style paint',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{/* Top spacer for virtualization */}
|
||||||
<React.Fragment key={item.id}>
|
<div style={{ height: topSpacer }} />
|
||||||
{/* Split marker */}
|
|
||||||
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
|
|
||||||
|
|
||||||
{/* Item */}
|
{/* Visible items container */}
|
||||||
{renderItem(item, index, itemRefs)}
|
<div
|
||||||
</React.Fragment>
|
style={{
|
||||||
))}
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '1.5rem',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
// Prevent layout shifts during scrolling
|
||||||
|
containIntrinsicSize: '20rem 20rem',
|
||||||
|
contain: 'layout style',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleItems.map((item, visibleIndex) => {
|
||||||
|
const actualIndex = startIndex + visibleIndex;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
{/* Split marker */}
|
||||||
|
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
||||||
|
|
||||||
{/* End drop zone */}
|
{/* Item */}
|
||||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
{renderItem(item, actualIndex, itemRefs)}
|
||||||
<div
|
</React.Fragment>
|
||||||
data-drop-zone="end"
|
);
|
||||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
})}
|
||||||
dropTarget === 'end'
|
|
||||||
? 'ring-2 ring-green-500 bg-green-50'
|
{/* End drop zone - inline with pages */}
|
||||||
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
||||||
}`}
|
<div
|
||||||
style={{ borderRadius: '12px' }}
|
data-drop-zone="end"
|
||||||
onDragOver={onDragOver}
|
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
||||||
onDragEnter={onEndZoneDragEnter}
|
dropTarget === 'end'
|
||||||
onDragLeave={onDragLeave}
|
? 'ring-2 ring-green-500 bg-green-50'
|
||||||
onDrop={(e) => onDrop(e, 'end')}
|
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
||||||
>
|
}`}
|
||||||
<div className="text-gray-500 text-sm text-center font-medium">
|
style={{ borderRadius: '12px' }}
|
||||||
Drop here to<br />move to end
|
onDragOver={onDragOver}
|
||||||
|
onDragEnter={onEndZoneDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, 'end')}
|
||||||
|
>
|
||||||
|
<div className="text-gray-500 text-sm text-center font-medium">
|
||||||
|
Drop here to<br />move to end
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -132,48 +132,79 @@ const PageEditor = ({
|
|||||||
}
|
}
|
||||||
console.log(`🎬 Will use ${(processedFile?.pages?.length || 0) > 0 ? 'PROCESSED' : 'FALLBACK'} pages`);
|
console.log(`🎬 Will use ${(processedFile?.pages?.length || 0) > 0 ? 'PROCESSED' : 'FALLBACK'} pages`);
|
||||||
|
|
||||||
// Convert processed pages to PageEditor format
|
// Convert processed pages to PageEditor format or create placeholders from metadata
|
||||||
// All processing is now handled by FileProcessingService when files are added
|
let pages: PDFPage[] = [];
|
||||||
const pages: PDFPage[] = processedFile?.pages && processedFile.pages.length > 0
|
|
||||||
? processedFile.pages.map((page, index) => {
|
|
||||||
const pageId = `${primaryFileId}-page-${index + 1}`;
|
|
||||||
// Try multiple sources for thumbnails in order of preference:
|
|
||||||
// 1. Processed data thumbnail
|
|
||||||
// 2. Cached thumbnail from previous generation
|
|
||||||
// 3. For page 1: FileRecord's thumbnailUrl (from FileProcessingService)
|
|
||||||
let thumbnail = page.thumbnail || null;
|
|
||||||
const cachedThumbnail = getThumbnailFromCache(pageId);
|
|
||||||
if (!thumbnail && cachedThumbnail) {
|
|
||||||
thumbnail = cachedThumbnail;
|
|
||||||
console.log(`📸 PageEditor: Using cached thumbnail for page ${index + 1} (${pageId})`);
|
|
||||||
}
|
|
||||||
if (!thumbnail && index === 0) {
|
|
||||||
// For page 1, use the thumbnail from FileProcessingService
|
|
||||||
thumbnail = primaryFileRecord.thumbnailUrl || null;
|
|
||||||
if (thumbnail) {
|
|
||||||
addThumbnailToCache(pageId, thumbnail);
|
|
||||||
console.log(`📸 PageEditor: Using FileProcessingService thumbnail for page 1 (${pageId})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||||
|
// Use fully processed pages with thumbnails
|
||||||
|
pages = processedFile.pages.map((page, index) => {
|
||||||
|
const pageId = `${primaryFileId}-page-${index + 1}`;
|
||||||
|
// Try multiple sources for thumbnails in order of preference:
|
||||||
|
// 1. Processed data thumbnail
|
||||||
|
// 2. Cached thumbnail from previous generation
|
||||||
|
// 3. For page 1: FileRecord's thumbnailUrl (from FileProcessingService)
|
||||||
|
let thumbnail = page.thumbnail || null;
|
||||||
|
const cachedThumbnail = getThumbnailFromCache(pageId);
|
||||||
|
if (!thumbnail && cachedThumbnail) {
|
||||||
|
thumbnail = cachedThumbnail;
|
||||||
|
console.log(`📸 PageEditor: Using cached thumbnail for page ${index + 1} (${pageId})`);
|
||||||
|
}
|
||||||
|
if (!thumbnail && index === 0) {
|
||||||
|
// For page 1, use the thumbnail from FileProcessingService
|
||||||
|
thumbnail = primaryFileRecord.thumbnailUrl || null;
|
||||||
|
if (thumbnail) {
|
||||||
|
addThumbnailToCache(pageId, thumbnail);
|
||||||
|
console.log(`📸 PageEditor: Using FileProcessingService thumbnail for page 1 (${pageId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pageId,
|
id: pageId,
|
||||||
pageNumber: index + 1,
|
pageNumber: index + 1,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: page.rotation || 0,
|
rotation: page.rotation || 0,
|
||||||
selected: false,
|
selected: false,
|
||||||
splitBefore: page.splitBefore || false,
|
splitBefore: page.splitBefore || false,
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
: [{ // Fallback while FileProcessingService is working
|
} else if (processedFile?.totalPages && processedFile.totalPages > 0) {
|
||||||
id: `${primaryFileId}-page-1`,
|
// Create placeholder pages from metadata while thumbnails are being generated
|
||||||
pageNumber: 1,
|
console.log(`🎬 PageEditor: Creating ${processedFile.totalPages} placeholder pages from metadata`);
|
||||||
thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null,
|
pages = Array.from({ length: processedFile.totalPages }, (_, index) => {
|
||||||
|
const pageId = `${primaryFileId}-page-${index + 1}`;
|
||||||
|
|
||||||
|
// Check for existing cached thumbnail
|
||||||
|
let thumbnail = getThumbnailFromCache(pageId) || null;
|
||||||
|
|
||||||
|
// For page 1, try to use the FileRecord thumbnail
|
||||||
|
if (!thumbnail && index === 0) {
|
||||||
|
thumbnail = primaryFileRecord.thumbnailUrl || null;
|
||||||
|
if (thumbnail) {
|
||||||
|
addThumbnailToCache(pageId, thumbnail);
|
||||||
|
console.log(`📸 PageEditor: Using FileProcessingService thumbnail for placeholder page 1 (${pageId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: pageId,
|
||||||
|
pageNumber: index + 1,
|
||||||
|
thumbnail, // Will be null initially, populated by PageThumbnail components
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
selected: false,
|
selected: false,
|
||||||
splitBefore: false,
|
splitBefore: false,
|
||||||
}];
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Ultimate fallback - single page while we wait for metadata
|
||||||
|
pages = [{
|
||||||
|
id: `${primaryFileId}-page-1`,
|
||||||
|
pageNumber: 1,
|
||||||
|
thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null,
|
||||||
|
rotation: 0,
|
||||||
|
selected: false,
|
||||||
|
splitBefore: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
// Create document with determined pages
|
// Create document with determined pages
|
||||||
|
|
||||||
@ -1123,7 +1154,7 @@ const PageEditor = ({
|
|||||||
const displayedPages = displayDocument?.pages || [];
|
const displayedPages = displayDocument?.pages || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||||
|
|
||||||
{showEmpty && (
|
{showEmpty && (
|
||||||
|
@ -81,7 +81,7 @@ const PageThumbnail = React.memo(({
|
|||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { getThumbnailFromCache } = useThumbnailGeneration();
|
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||||
|
|
||||||
// Update thumbnail URL when page prop changes - prevent redundant updates
|
// Update thumbnail URL when page prop changes - prevent redundant updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -91,27 +91,43 @@ const PageThumbnail = React.memo(({
|
|||||||
}
|
}
|
||||||
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
|
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
|
||||||
|
|
||||||
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
|
// Request thumbnail generation if not available (optimized for performance)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl || !originalFile) {
|
||||||
return; // Skip if we already have a thumbnail
|
return; // Skip if we already have a thumbnail or no original file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for thumbnail in cache (lightweight polling every 500ms)
|
// Check cache first without async call
|
||||||
const pollInterval = setInterval(() => {
|
const cachedThumbnail = getThumbnailFromCache(page.id);
|
||||||
// Check if thumbnail is now available in cache
|
if (cachedThumbnail) {
|
||||||
const cachedThumbnail = getThumbnailFromCache(page.id);
|
setThumbnailUrl(cachedThumbnail);
|
||||||
if (cachedThumbnail) {
|
return;
|
||||||
setThumbnailUrl(cachedThumbnail);
|
}
|
||||||
clearInterval(pollInterval); // Stop polling once found
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Cleanup interval
|
let cancelled = false;
|
||||||
return () => {
|
|
||||||
clearInterval(pollInterval);
|
const loadThumbnail = async () => {
|
||||||
|
try {
|
||||||
|
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber);
|
||||||
|
|
||||||
|
// Only update if component is still mounted and we got a result
|
||||||
|
if (!cancelled && thumbnail) {
|
||||||
|
setThumbnailUrl(thumbnail);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [page.pageNumber, page.id]); // Remove thumbnailUrl dependency to stabilize effect
|
|
||||||
|
loadThumbnail();
|
||||||
|
|
||||||
|
// Cleanup function to prevent state updates after unmount
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
|
||||||
|
|
||||||
|
|
||||||
// Register this component with pageRefs for animations
|
// Register this component with pageRefs for animations
|
||||||
|
@ -1,6 +1,110 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||||
|
|
||||||
|
// Request queue to handle concurrent thumbnail requests
|
||||||
|
interface ThumbnailRequest {
|
||||||
|
pageId: string;
|
||||||
|
file: File;
|
||||||
|
pageNumber: number;
|
||||||
|
resolve: (thumbnail: string | null) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global request queue (shared across all hook instances)
|
||||||
|
const requestQueue: ThumbnailRequest[] = [];
|
||||||
|
let isProcessingQueue = false;
|
||||||
|
let batchTimer: number | null = null;
|
||||||
|
|
||||||
|
// Track active thumbnail requests to prevent duplicates across components
|
||||||
|
const activeRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
// Batch processing configuration
|
||||||
|
const BATCH_SIZE = 50; // Process thumbnails in batches of 50
|
||||||
|
const BATCH_DELAY = 100; // Wait 100ms to collect requests before processing
|
||||||
|
const PRIORITY_BATCH_DELAY = 50; // Faster processing for the first batch (visible pages)
|
||||||
|
|
||||||
|
// Process the queue in batches for better performance
|
||||||
|
async function processRequestQueue() {
|
||||||
|
if (isProcessingQueue || requestQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessingQueue = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (requestQueue.length > 0) {
|
||||||
|
// Sort queue by page number to prioritize visible pages first
|
||||||
|
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
|
||||||
|
|
||||||
|
// Take a batch of requests (same file only for efficiency)
|
||||||
|
const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
|
||||||
|
const batch = requestQueue.splice(0, batchSize);
|
||||||
|
|
||||||
|
// Group by file to process efficiently
|
||||||
|
const fileGroups = new Map<File, ThumbnailRequest[]>();
|
||||||
|
|
||||||
|
// First, resolve any cached thumbnails immediately
|
||||||
|
const uncachedRequests: ThumbnailRequest[] = [];
|
||||||
|
|
||||||
|
for (const request of batch) {
|
||||||
|
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
|
||||||
|
if (cached) {
|
||||||
|
request.resolve(cached);
|
||||||
|
} else {
|
||||||
|
uncachedRequests.push(request);
|
||||||
|
|
||||||
|
if (!fileGroups.has(request.file)) {
|
||||||
|
fileGroups.set(request.file, []);
|
||||||
|
}
|
||||||
|
fileGroups.get(request.file)!.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each file group with batch thumbnail generation
|
||||||
|
for (const [file, requests] of fileGroups) {
|
||||||
|
if (requests.length === 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageNumbers = requests.map(req => req.pageNumber);
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||||
|
|
||||||
|
const results = await thumbnailGenerationService.generateThumbnails(
|
||||||
|
arrayBuffer,
|
||||||
|
pageNumbers,
|
||||||
|
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
|
||||||
|
(progress) => {
|
||||||
|
// Optional: Could emit progress events here for UI feedback
|
||||||
|
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Match results back to requests and resolve
|
||||||
|
for (const request of requests) {
|
||||||
|
const result = results.find(r => r.pageNumber === request.pageNumber);
|
||||||
|
|
||||||
|
if (result && result.success && result.thumbnail) {
|
||||||
|
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
|
||||||
|
request.resolve(result.thumbnail);
|
||||||
|
} else {
|
||||||
|
console.warn(`No result for page ${request.pageNumber}`);
|
||||||
|
request.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
|
||||||
|
// Reject all requests in this batch
|
||||||
|
requests.forEach(request => request.reject(error as Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isProcessingQueue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for tools that want to use thumbnail generation
|
* Hook for tools that want to use thumbnail generation
|
||||||
* Tools can choose whether to include visual features
|
* Tools can choose whether to include visual features
|
||||||
@ -42,15 +146,83 @@ export function useThumbnailGeneration() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const destroyThumbnails = useCallback(() => {
|
const destroyThumbnails = useCallback(() => {
|
||||||
|
// Clear any pending batch timer
|
||||||
|
if (batchTimer) {
|
||||||
|
clearTimeout(batchTimer);
|
||||||
|
batchTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the queue and active requests
|
||||||
|
requestQueue.length = 0;
|
||||||
|
activeRequests.clear();
|
||||||
|
isProcessingQueue = false;
|
||||||
|
|
||||||
thumbnailGenerationService.destroy();
|
thumbnailGenerationService.destroy();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestThumbnail = useCallback(async (
|
||||||
|
pageId: string,
|
||||||
|
file: File,
|
||||||
|
pageNumber: number
|
||||||
|
): Promise<string | null> => {
|
||||||
|
// Check cache first for immediate return
|
||||||
|
const cached = thumbnailGenerationService.getThumbnailFromCache(pageId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this request is already being processed globally
|
||||||
|
const activeRequest = activeRequests.get(pageId);
|
||||||
|
if (activeRequest) {
|
||||||
|
return activeRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request promise and track it globally
|
||||||
|
const requestPromise = new Promise<string | null>((resolve, reject) => {
|
||||||
|
requestQueue.push({
|
||||||
|
pageId,
|
||||||
|
file,
|
||||||
|
pageNumber,
|
||||||
|
resolve: (result: string | null) => {
|
||||||
|
activeRequests.delete(pageId);
|
||||||
|
resolve(result);
|
||||||
|
},
|
||||||
|
reject: (error: Error) => {
|
||||||
|
activeRequests.delete(pageId);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule batch processing with a small delay to collect more requests
|
||||||
|
if (batchTimer) {
|
||||||
|
clearTimeout(batchTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use shorter delay for the first batch (pages 1-50) to show visible content faster
|
||||||
|
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
|
||||||
|
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
|
||||||
|
|
||||||
|
batchTimer = window.setTimeout(() => {
|
||||||
|
processRequestQueue().catch(error => {
|
||||||
|
console.error('Error processing thumbnail request queue:', error);
|
||||||
|
});
|
||||||
|
batchTimer = null;
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track this request to prevent duplicates
|
||||||
|
activeRequests.set(pageId, requestPromise);
|
||||||
|
|
||||||
|
return requestPromise;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generateThumbnails,
|
generateThumbnails,
|
||||||
addThumbnailToCache,
|
addThumbnailToCache,
|
||||||
getThumbnailFromCache,
|
getThumbnailFromCache,
|
||||||
getCacheStats,
|
getCacheStats,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
destroyThumbnails
|
destroyThumbnails,
|
||||||
|
requestThumbnail
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -26,7 +26,6 @@ export class ThumbnailGenerationService {
|
|||||||
private workers: Worker[] = [];
|
private workers: Worker[] = [];
|
||||||
private activeJobs = new Map<string, { resolve: Function; reject: Function; onProgress?: Function }>();
|
private activeJobs = new Map<string, { resolve: Function; reject: Function; onProgress?: Function }>();
|
||||||
private jobCounter = 0;
|
private jobCounter = 0;
|
||||||
private isGenerating = false;
|
|
||||||
|
|
||||||
// Session-based thumbnail cache
|
// Session-based thumbnail cache
|
||||||
private thumbnailCache = new Map<string, CachedThumbnail>();
|
private thumbnailCache = new Map<string, CachedThumbnail>();
|
||||||
@ -133,11 +132,11 @@ export class ThumbnailGenerationService {
|
|||||||
options: ThumbnailGenerationOptions = {},
|
options: ThumbnailGenerationOptions = {},
|
||||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||||
): Promise<ThumbnailResult[]> {
|
): Promise<ThumbnailResult[]> {
|
||||||
if (this.isGenerating) {
|
// Create unique job ID to track this specific generation request
|
||||||
throw new Error('Thumbnail generation already in progress');
|
const jobId = `thumbnails-${++this.jobCounter}`;
|
||||||
}
|
|
||||||
|
|
||||||
this.isGenerating = true;
|
// Instead of blocking globally, we'll track individual generation jobs
|
||||||
|
// This allows multiple thumbnail generation requests to run concurrently
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scale = 0.2,
|
scale = 0.2,
|
||||||
@ -207,7 +206,7 @@ export class ThumbnailGenerationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
||||||
} finally {
|
} finally {
|
||||||
this.isGenerating = false;
|
// Individual job completed, no need to reset global flag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,7 +400,6 @@ export class ThumbnailGenerationService {
|
|||||||
*/
|
*/
|
||||||
stopGeneration(): void {
|
stopGeneration(): void {
|
||||||
this.activeJobs.clear();
|
this.activeJobs.clear();
|
||||||
this.isGenerating = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -411,7 +409,6 @@ export class ThumbnailGenerationService {
|
|||||||
this.workers.forEach(worker => worker.terminate());
|
this.workers.forEach(worker => worker.terminate());
|
||||||
this.workers = [];
|
this.workers = [];
|
||||||
this.activeJobs.clear();
|
this.activeJobs.clear();
|
||||||
this.isGenerating = false;
|
|
||||||
this.clearThumbnailCache();
|
this.clearThumbnailCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user