From aff610448b09402943e73a066549733d1df1e8c1 Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Tue, 19 Aug 2025 00:55:48 +0100 Subject: [PATCH] Clean up --- frontend/public/thumbnailWorker.js | 26 -- .../components/pageEditor/PageThumbnail.tsx | 1 + .../services/thumbnailGenerationService.ts | 335 +++------------ frontend/src/types/operations.ts | 292 ------------- frontend/src/utils/thumbnailUtils.ts | 387 ++++-------------- 5 files changed, 139 insertions(+), 902 deletions(-) delete mode 100644 frontend/public/thumbnailWorker.js delete mode 100644 frontend/src/types/operations.ts diff --git a/frontend/public/thumbnailWorker.js b/frontend/public/thumbnailWorker.js deleted file mode 100644 index 197649192..000000000 --- a/frontend/public/thumbnailWorker.js +++ /dev/null @@ -1,26 +0,0 @@ -// Web Worker for lightweight data processing (not PDF rendering) -// PDF rendering must stay on main thread due to DOM dependencies - -self.onmessage = async function(e) { - const { type, data, jobId } = e.data; - - try { - // Handle PING for worker health check - if (type === 'PING') { - self.postMessage({ type: 'PONG', jobId }); - return; - } - - if (type === 'GENERATE_THUMBNAILS') { - // Web Workers cannot do PDF rendering due to DOM dependencies - // This is expected to fail and trigger main thread fallback - throw new Error('PDF rendering requires main thread (DOM access needed)'); - } - } catch (error) { - self.postMessage({ - type: 'ERROR', - jobId, - data: { error: error.message } - }); - } -}; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 311c6c8c9..eea7c682d 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -154,6 +154,7 @@ const PageThumbnail = React.memo(({ : undefined; onReorderPages(page.pageNumber, targetIndex, pagesToMove); } + } } }); diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts index 0ca278448..9946fba9f 100644 --- a/frontend/src/services/thumbnailGenerationService.ts +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -1,5 +1,5 @@ /** - * High-performance thumbnail generation service using Web Workers + * High-performance thumbnail generation service using main thread processing */ interface ThumbnailResult { @@ -23,108 +23,17 @@ interface CachedThumbnail { } export class ThumbnailGenerationService { - private workers: Worker[] = []; - private activeJobs = new Map(); - private jobCounter = 0; - // Session-based thumbnail cache private thumbnailCache = new Map(); private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit private currentCacheSize = 0; constructor(private maxWorkers: number = 3) { - /** - * NOTE: PDF rendering requires DOM access (document, canvas, etc.) which isn't - * available in Web Workers. This service attempts Web Worker setup but will - * gracefully fallback to optimized main thread processing when Workers fail. - * This is expected behavior, not an error. - */ - this.initializeWorkers(); - } - - private initializeWorkers(): void { - const workerPromises: Promise[] = []; - - for (let i = 0; i < this.maxWorkers; i++) { - const workerPromise = new Promise((resolve) => { - try { - const worker = new Worker('/thumbnailWorker.js'); - let workerReady = false; - let pingTimeout: NodeJS.Timeout; - - worker.onmessage = (e) => { - const { type, data, jobId } = e.data; - - // Handle PONG response to confirm worker is ready - if (type === 'PONG') { - workerReady = true; - clearTimeout(pingTimeout); - resolve(worker); - return; - } - - const job = this.activeJobs.get(jobId); - if (!job) return; - - switch (type) { - case 'PROGRESS': - if (job.onProgress) { - job.onProgress(data); - } - break; - - case 'COMPLETE': - job.resolve(data.thumbnails); - this.activeJobs.delete(jobId); - break; - - case 'ERROR': - job.reject(new Error(data.error)); - this.activeJobs.delete(jobId); - break; - } - }; - - worker.onerror = (error) => { - clearTimeout(pingTimeout); - worker.terminate(); - resolve(null); - }; - - // Test worker with timeout - pingTimeout = setTimeout(() => { - if (!workerReady) { - worker.terminate(); - resolve(null); - } - }, 1000); // Quick timeout since we expect failure - - // Send PING to test worker - try { - worker.postMessage({ type: 'PING' }); - } catch (pingError) { - clearTimeout(pingTimeout); - worker.terminate(); - resolve(null); - } - - } catch (error) { - resolve(null); - } - }); - - workerPromises.push(workerPromise); - } - - // Wait for all workers to initialize or fail - Promise.all(workerPromises).then((workers) => { - this.workers = workers.filter((w): w is Worker => w !== null); - // Workers expected to fail due to PDF.js DOM requirements - no logging needed - }); + // PDF rendering requires DOM access, so we use optimized main thread processing } /** - * Generate thumbnails for multiple pages using Web Workers + * Generate thumbnails for multiple pages using main thread processing */ async generateThumbnails( pdfArrayBuffer: ArrayBuffer, @@ -132,86 +41,16 @@ export class ThumbnailGenerationService { options: ThumbnailGenerationOptions = {}, onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void ): Promise { - // Create unique job ID to track this specific generation request - const jobId = `thumbnails-${++this.jobCounter}`; - - // Instead of blocking globally, we'll track individual generation jobs - // This allows multiple thumbnail generation requests to run concurrently - const { scale = 0.2, - quality = 0.8, - batchSize = 20, // Pages per worker - parallelBatches = this.maxWorkers + quality = 0.8 } = options; - try { - // Check if workers are available, fallback to main thread if not - if (this.workers.length === 0) { - return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); - } - - // Split pages across workers - const workerBatches = this.distributeWork(pageNumbers, this.workers.length); - const jobPromises: Promise[] = []; - - for (let i = 0; i < workerBatches.length; i++) { - const batch = workerBatches[i]; - if (batch.length === 0) continue; - - const worker = this.workers[i % this.workers.length]; - const jobId = `job-${++this.jobCounter}`; - - const promise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.activeJobs.delete(jobId); - reject(new Error(`Worker job ${jobId} timed out`)); - }, 60000); // 1 minute timeout - - // Create job with timeout handling - this.activeJobs.set(jobId, { - resolve: (result: any) => { - clearTimeout(timeout); - resolve(result); - }, - reject: (error: any) => { - clearTimeout(timeout); - reject(error); - }, - onProgress: onProgress - }); - - worker.postMessage({ - type: 'GENERATE_THUMBNAILS', - jobId, - data: { - pdfArrayBuffer, - pageNumbers: batch, - scale, - quality - } - }); - }); - - jobPromises.push(promise); - } - - // Wait for all workers to complete - const results = await Promise.all(jobPromises); - - // Flatten and sort results by page number - const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber); - return allThumbnails; - - } catch (error) { - return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); - } finally { - // Individual job completed, no need to reset global flag - } + return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); } /** - * Fallback thumbnail generation on main thread + * Main thread thumbnail generation with batching for UI responsiveness */ private async generateThumbnailsMainThread( pdfArrayBuffer: ArrayBuffer, @@ -220,13 +59,9 @@ export class ThumbnailGenerationService { quality: number, onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void ): Promise { - // Import PDF.js dynamically for main thread const { getDocument } = await import('pdfjs-dist'); - - // Load PDF once const pdf = await getDocument({ data: pdfArrayBuffer }).promise; - const allResults: ThumbnailResult[] = []; let completed = 0; const batchSize = 5; // Small batches for UI responsiveness @@ -277,141 +112,81 @@ export class ThumbnailGenerationService { }); } - // Small delay to keep UI responsive - if (i + batchSize < pageNumbers.length) { - await new Promise(resolve => setTimeout(resolve, 10)); - } + // Yield control to prevent UI blocking + await new Promise(resolve => setTimeout(resolve, 1)); } - // Clean up - pdf.destroy(); - - return allResults.filter(r => r.success); + await pdf.destroy(); + return allResults; } /** - * Distribute work evenly across workers - */ - private distributeWork(pageNumbers: number[], numWorkers: number): number[][] { - const batches: number[][] = Array(numWorkers).fill(null).map(() => []); - - pageNumbers.forEach((pageNum, index) => { - const workerIndex = index % numWorkers; - batches[workerIndex].push(pageNum); - }); - - return batches; - } - - /** - * Generate a single thumbnail (fallback for individual pages) - */ - async generateSingleThumbnail( - pdfArrayBuffer: ArrayBuffer, - pageNumber: number, - options: ThumbnailGenerationOptions = {} - ): Promise { - const results = await this.generateThumbnails(pdfArrayBuffer, [pageNumber], options); - - if (results.length === 0 || !results[0].success) { - throw new Error(`Failed to generate thumbnail for page ${pageNumber}`); - } - - return results[0].thumbnail; - } - - /** - * Add thumbnail to cache with size management - */ - addThumbnailToCache(pageId: string, thumbnail: string): void { - const thumbnailSizeBytes = thumbnail.length * 0.75; // Rough base64 size estimate - const now = Date.now(); - - // Add new thumbnail - this.thumbnailCache.set(pageId, { - thumbnail, - lastUsed: now, - sizeBytes: thumbnailSizeBytes - }); - - this.currentCacheSize += thumbnailSizeBytes; - - // If we exceed 1GB, trigger cleanup - if (this.currentCacheSize > this.maxCacheSizeBytes) { - this.cleanupThumbnailCache(); - } - } - - /** - * Get thumbnail from cache and update last used timestamp + * Cache management */ getThumbnailFromCache(pageId: string): string | null { const cached = this.thumbnailCache.get(pageId); - if (!cached) return null; - - // Update last used timestamp - cached.lastUsed = Date.now(); - - return cached.thumbnail; + if (cached) { + cached.lastUsed = Date.now(); + return cached.thumbnail; + } + return null; } - /** - * Clean up cache using LRU eviction - */ - private cleanupThumbnailCache(): void { - const entries = Array.from(this.thumbnailCache.entries()); + addThumbnailToCache(pageId: string, thumbnail: string): void { + const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string - // Sort by last used (oldest first) - entries.sort(([, a], [, b]) => a.lastUsed - b.lastUsed); + // Enforce cache size limits + while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) { + this.evictLeastRecentlyUsed(); + } + + this.thumbnailCache.set(pageId, { + thumbnail, + lastUsed: Date.now(), + sizeBytes + }); - this.thumbnailCache.clear(); - this.currentCacheSize = 0; - const targetSize = this.maxCacheSizeBytes * 0.8; // Clean to 80% of limit - - // Keep most recently used entries until we hit target size - for (let i = entries.length - 1; i >= 0 && this.currentCacheSize < targetSize; i--) { - const [key, value] = entries[i]; - this.thumbnailCache.set(key, value); - this.currentCacheSize += value.sizeBytes; + this.currentCacheSize += sizeBytes; + } + + private evictLeastRecentlyUsed(): void { + let oldestEntry: [string, CachedThumbnail] | null = null; + let oldestTime = Date.now(); + + for (const [key, value] of this.thumbnailCache.entries()) { + if (value.lastUsed < oldestTime) { + oldestTime = value.lastUsed; + oldestEntry = [key, value]; + } + } + + if (oldestEntry) { + this.thumbnailCache.delete(oldestEntry[0]); + this.currentCacheSize -= oldestEntry[1].sizeBytes; } } - /** - * Clear all cached thumbnails - */ - clearThumbnailCache(): void { - this.thumbnailCache.clear(); - this.currentCacheSize = 0; - } - - /** - * Get cache statistics - */ getCacheStats() { return { - entries: this.thumbnailCache.size, - totalSizeBytes: this.currentCacheSize, + size: this.thumbnailCache.size, + sizeBytes: this.currentCacheSize, maxSizeBytes: this.maxCacheSizeBytes }; } - /** - * Stop generation but keep cache and workers alive - */ stopGeneration(): void { - this.activeJobs.clear(); + // No-op since we removed workers + } + + clearCache(): void { + this.thumbnailCache.clear(); + this.currentCacheSize = 0; } - /** - * Terminate all workers and clear cache (only on explicit cleanup) - */ destroy(): void { - this.workers.forEach(worker => worker.terminate()); - this.workers = []; - this.activeJobs.clear(); - this.clearThumbnailCache(); + this.clearCache(); } } -// Export singleton instance +// Global singleton instance export const thumbnailGenerationService = new ThumbnailGenerationService(); \ No newline at end of file diff --git a/frontend/src/types/operations.ts b/frontend/src/types/operations.ts deleted file mode 100644 index 644fec24c..000000000 --- a/frontend/src/types/operations.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Typed operation model with discriminated unions - * Centralizes all PDF operations with proper type safety - */ - -import { FileId } from './fileContext'; - -export type OperationId = string; - -export type OperationStatus = - | 'idle' - | 'preparing' - | 'uploading' - | 'processing' - | 'completed' - | 'failed' - | 'canceled'; - -// Base operation interface -export interface BaseOperation { - id: OperationId; - type: string; - status: OperationStatus; - progress: number; - error?: string | null; - createdAt: number; - startedAt?: number; - completedAt?: number; - abortController?: AbortController; -} - -// Split operations -export type SplitMode = - | 'pages' - | 'size' - | 'duplicates' - | 'bookmarks' - | 'sections'; - -export interface SplitPagesParams { - mode: 'pages'; - pages: number[]; -} - -export interface SplitSizeParams { - mode: 'size'; - maxSizeBytes: number; -} - -export interface SplitDuplicatesParams { - mode: 'duplicates'; - tolerance?: number; -} - -export interface SplitBookmarksParams { - mode: 'bookmarks'; - level?: number; -} - -export interface SplitSectionsParams { - mode: 'sections'; - sectionCount: number; -} - -export type SplitParams = - | SplitPagesParams - | SplitSizeParams - | SplitDuplicatesParams - | SplitBookmarksParams - | SplitSectionsParams; - -export interface SplitOperation extends BaseOperation { - type: 'split'; - inputFileId: FileId; - params: SplitParams; - outputFileIds?: FileId[]; -} - -// Merge operations -export interface MergeOperation extends BaseOperation { - type: 'merge'; - inputFileIds: FileId[]; - params: { - sortBy?: 'name' | 'size' | 'date' | 'custom'; - customOrder?: FileId[]; - bookmarks?: boolean; - }; - outputFileId?: FileId; -} - -// Compress operations -export interface CompressOperation extends BaseOperation { - type: 'compress'; - inputFileId: FileId; - params: { - level: 'low' | 'medium' | 'high' | 'extreme'; - imageQuality?: number; // 0-100 - grayscale?: boolean; - removeAnnotations?: boolean; - }; - outputFileId?: FileId; -} - -// Convert operations -export type ConvertFormat = - | 'pdf' - | 'docx' - | 'pptx' - | 'xlsx' - | 'html' - | 'txt' - | 'jpg' - | 'png'; - -export interface ConvertOperation extends BaseOperation { - type: 'convert'; - inputFileIds: FileId[]; - params: { - targetFormat: ConvertFormat; - imageSettings?: { - quality?: number; - dpi?: number; - colorSpace?: 'rgb' | 'grayscale' | 'cmyk'; - }; - pdfSettings?: { - pdfStandard?: 'PDF/A-1' | 'PDF/A-2' | 'PDF/A-3'; - compliance?: boolean; - }; - }; - outputFileIds?: FileId[]; -} - -// OCR operations -export interface OcrOperation extends BaseOperation { - type: 'ocr'; - inputFileId: FileId; - params: { - languages: string[]; - mode: 'searchable' | 'text-only' | 'overlay'; - preprocess?: boolean; - deskew?: boolean; - }; - outputFileId?: FileId; -} - -// Security operations -export interface SecurityOperation extends BaseOperation { - type: 'security'; - inputFileId: FileId; - params: { - action: 'encrypt' | 'decrypt' | 'sign' | 'watermark'; - password?: string; - permissions?: { - printing?: boolean; - copying?: boolean; - editing?: boolean; - annotations?: boolean; - }; - watermark?: { - text: string; - position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; - opacity: number; - }; - }; - outputFileId?: FileId; -} - -// Union type for all operations -export type Operation = - | SplitOperation - | MergeOperation - | CompressOperation - | ConvertOperation - | OcrOperation - | SecurityOperation; - -// Operation state management -export interface OperationState { - operations: Record; - queue: OperationId[]; - active: OperationId[]; - history: OperationId[]; -} - -// Operation creation helpers -export function createOperationId(): OperationId { - return `op-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; -} - -export function createBaseOperation(type: string): BaseOperation { - return { - id: createOperationId(), - type, - status: 'idle', - progress: 0, - error: null, - createdAt: Date.now(), - abortController: new AbortController() - }; -} - -// Type guards for operations -export function isSplitOperation(op: Operation): op is SplitOperation { - return op.type === 'split'; -} - -export function isMergeOperation(op: Operation): op is MergeOperation { - return op.type === 'merge'; -} - -export function isCompressOperation(op: Operation): op is CompressOperation { - return op.type === 'compress'; -} - -export function isConvertOperation(op: Operation): op is ConvertOperation { - return op.type === 'convert'; -} - -export function isOcrOperation(op: Operation): op is OcrOperation { - return op.type === 'ocr'; -} - -export function isSecurityOperation(op: Operation): op is SecurityOperation { - return op.type === 'security'; -} - -// Operation status helpers -export function isOperationActive(op: Operation): boolean { - return ['preparing', 'uploading', 'processing'].includes(op.status); -} - -export function isOperationComplete(op: Operation): boolean { - return op.status === 'completed'; -} - -export function isOperationFailed(op: Operation): boolean { - return op.status === 'failed'; -} - -export function canRetryOperation(op: Operation): boolean { - return op.status === 'failed' && !!op.abortController && !op.abortController.signal.aborted; -} - -// Operation validation -export function validateSplitParams(params: SplitParams): string | null { - switch (params.mode) { - case 'pages': - if (!params.pages.length) return 'No pages specified'; - if (params.pages.some(p => p < 1)) return 'Invalid page numbers'; - break; - case 'size': - if (params.maxSizeBytes <= 0) return 'Invalid size limit'; - break; - case 'sections': - if (params.sectionCount < 2) return 'Section count must be at least 2'; - break; - } - return null; -} - -export function validateMergeParams(params: MergeOperation['params'], fileIds: FileId[]): string | null { - if (fileIds.length < 2) return 'At least 2 files required for merge'; - if (params.sortBy === 'custom' && !params.customOrder?.length) { - return 'Custom order required when sort by custom is selected'; - } - return null; -} - -export function validateCompressParams(params: CompressOperation['params']): string | null { - if (params.imageQuality !== undefined && (params.imageQuality < 0 || params.imageQuality > 100)) { - return 'Image quality must be between 0-100'; - } - return null; -} - -// Operation result types -export interface OperationResult { - operationId: OperationId; - success: boolean; - outputFileIds: FileId[]; - error?: string; - metadata?: Record; -} - -// Operation events for pub/sub -export type OperationEvent = - | { type: 'operation:created'; operation: Operation } - | { type: 'operation:started'; operationId: OperationId } - | { type: 'operation:progress'; operationId: OperationId; progress: number } - | { type: 'operation:completed'; operationId: OperationId; result: OperationResult } - | { type: 'operation:failed'; operationId: OperationId; error: string } - | { type: 'operation:canceled'; operationId: OperationId }; \ No newline at end of file diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 22fb86d15..013e34597 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -7,20 +7,42 @@ export interface ThumbnailWithMetadata { /** * Calculate thumbnail scale based on file size - * Smaller files get higher quality, larger files get lower quality */ export function calculateScaleFromFileSize(fileSize: number): number { const MB = 1024 * 1024; - - if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality - if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality - if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality - if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality - return 0.15; // 30MB+: Low quality + if (fileSize < 1 * MB) return 0.6; + if (fileSize < 5 * MB) return 0.4; + if (fileSize < 15 * MB) return 0.3; + if (fileSize < 30 * MB) return 0.2; + return 0.15; } /** - * Generate modern placeholder thumbnail with file extension + * Get file type color scheme + */ +function getFileTypeColor(extension: string): { bg: string; text: string; icon: string } { + const ext = extension.toLowerCase(); + + const colorMap: Record = { + pdf: { bg: '#ff4444', text: '#ffffff', icon: '📄' }, + doc: { bg: '#2196f3', text: '#ffffff', icon: '📝' }, + docx: { bg: '#2196f3', text: '#ffffff', icon: '📝' }, + xls: { bg: '#4caf50', text: '#ffffff', icon: '📊' }, + xlsx: { bg: '#4caf50', text: '#ffffff', icon: '📊' }, + ppt: { bg: '#ff9800', text: '#ffffff', icon: '📈' }, + pptx: { bg: '#ff9800', text: '#ffffff', icon: '📈' }, + txt: { bg: '#607d8b', text: '#ffffff', icon: '📃' }, + rtf: { bg: '#795548', text: '#ffffff', icon: '📃' }, + odt: { bg: '#3f51b5', text: '#ffffff', icon: '📝' }, + ods: { bg: '#009688', text: '#ffffff', icon: '📊' }, + odp: { bg: '#e91e63', text: '#ffffff', icon: '📈' } + }; + + return colorMap[ext] || { bg: '#9e9e9e', text: '#ffffff', icon: '📄' }; +} + +/** + * Generate simple placeholder thumbnail */ function generatePlaceholderThumbnail(file: File): string { const canvas = document.createElement('canvas'); @@ -28,301 +50,103 @@ function generatePlaceholderThumbnail(file: File): string { canvas.height = 150; const ctx = canvas.getContext('2d')!; - // Get file extension for color theming - const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE'; - const colorScheme = getFileTypeColorScheme(extension); + const extension = file.name.split('.').pop() || 'file'; + const colors = getFileTypeColor(extension); - // Create gradient background - const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); - gradient.addColorStop(0, colorScheme.bgTop); - gradient.addColorStop(1, colorScheme.bgBottom); + // Colored background + ctx.fillStyle = colors.bg; + ctx.fillRect(0, 0, canvas.width, canvas.height); - // Rounded rectangle background - drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8); - ctx.fillStyle = gradient; - ctx.fill(); - - // Subtle shadow/border - ctx.strokeStyle = colorScheme.border; - ctx.lineWidth = 1.5; - ctx.stroke(); - - // Modern document icon - drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon); - - // Extension badge - drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme); - - // File size with subtle styling - const sizeText = formatFileSize(file.size); - ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; - ctx.fillStyle = colorScheme.textSecondary; + // File icon + ctx.font = '48px Arial'; ctx.textAlign = 'center'; - ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15); + ctx.fillStyle = colors.text; + ctx.fillText(colors.icon, canvas.width / 2, canvas.height / 2); + + // File extension + ctx.font = '12px Arial'; + ctx.fillStyle = colors.text; + ctx.fillText(extension.toUpperCase(), canvas.width / 2, canvas.height - 20); return canvas.toDataURL(); } /** - * Get color scheme based on file extension + * Generate PDF thumbnail from first page */ -function getFileTypeColorScheme(extension: string) { - const schemes: Record = { - // Documents - 'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - - // Spreadsheets - 'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - - // Presentations - 'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - - // Archives - 'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - 'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - '7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - - // Default - 'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' } - }; +async function generatePdfThumbnail(file: File, scale: number): Promise { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await getDocument({ data: arrayBuffer }).promise; - return schemes[extension] || schemes['DEFAULT']; -} + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement("canvas"); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext("2d"); -/** - * Draw rounded rectangle - */ -function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); -} + if (!context) { + throw new Error('Could not get canvas context'); + } -/** - * Draw modern document icon - */ -function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, color: string) { - const size = 24; - ctx.fillStyle = color; - ctx.strokeStyle = color; - ctx.lineWidth = 2; + await page.render({ canvasContext: context, viewport }).promise; + const thumbnail = canvas.toDataURL(); - // Document body - drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3); - ctx.fill(); - - // Folded corner - ctx.beginPath(); - ctx.moveTo(centerX + size/2 - 6, centerY - size/2); - ctx.lineTo(centerX + size/2, centerY - size/2 + 6); - ctx.lineTo(centerX + size/2 - 6, centerY - size/2 + 6); - ctx.closePath(); - ctx.fillStyle = '#FFFFFF40'; - ctx.fill(); + pdf.destroy(); + return thumbnail; } -/** - * Draw extension badge - */ -function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) { - const badgeWidth = Math.max(extension.length * 8 + 16, 40); - const badgeHeight = 22; - - // Badge background - drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11); - ctx.fillStyle = colorScheme.badge; - ctx.fill(); - - // Badge text - ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; - ctx.fillStyle = colorScheme.textPrimary; - ctx.textAlign = 'center'; - ctx.fillText(extension, centerX, centerY + 4); -} - -/** - * Format file size for display - */ -function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; -} - - /** * Generate thumbnail for any file type - * Returns base64 data URL or undefined if generation fails */ export async function generateThumbnailForFile(file: File): Promise { - console.log(`🎯 generateThumbnailForFile: Starting for ${file.name} (${file.type}, ${file.size} bytes)`); - - // Skip thumbnail generation for very large files to avoid memory issues - if (file.size >= 100 * 1024 * 1024) { // 100MB limit - console.log('🎯 Skipping thumbnail generation for large file:', file.name); - const placeholder = generatePlaceholderThumbnail(file); - console.log('🎯 Generated placeholder thumbnail for large file:', file.name); - return placeholder; + // Skip very large files + if (file.size >= 100 * 1024 * 1024) { + return generatePlaceholderThumbnail(file); } - // Handle image files - use original file directly + // Handle image files if (file.type.startsWith('image/')) { - console.log('🎯 Creating blob URL for image file:', file.name); - const url = URL.createObjectURL(file); - console.log('🎯 Created image blob URL:', url); - return url; + return URL.createObjectURL(file); } // Handle PDF files - if (!file.type.startsWith('application/pdf')) { - console.log('🎯 File is not a PDF or image, generating placeholder:', file.name); - const placeholder = generatePlaceholderThumbnail(file); - console.log('🎯 Generated placeholder thumbnail for non-PDF file:', file.name); - return placeholder; + if (file.type.startsWith('application/pdf')) { + const scale = calculateScaleFromFileSize(file.size); + try { + return await generatePdfThumbnail(file, scale); + } catch (error) { + return generatePlaceholderThumbnail(file); + } } - // Calculate quality scale based on file size - console.log('🎯 Generating PDF thumbnail for', file.name); - const scale = calculateScaleFromFileSize(file.size); - console.log(`🎯 Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`); - try { - // Only read first 2MB for thumbnail generation to save memory - const chunkSize = 2 * 1024 * 1024; // 2MB - const chunk = file.slice(0, Math.min(chunkSize, file.size)); - const arrayBuffer = await chunk.arrayBuffer(); - - const pdf = await getDocument({ - data: arrayBuffer, - disableAutoFetch: true, - disableStream: true - }).promise; - - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale }); // Dynamic scale based on file size - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext("2d"); - - if (!context) { - throw new Error('Could not get canvas context'); - } - - await page.render({ canvasContext: context, viewport }).promise; - const thumbnail = canvas.toDataURL(); - - // Immediately clean up memory after thumbnail generation - pdf.destroy(); - console.log('🎯 PDF thumbnail successfully generated for', file.name, 'size:', thumbnail.length); - - return thumbnail; - } catch (error) { - console.warn('🎯 Error generating PDF thumbnail for', file.name, ':', error); - if (error instanceof Error) { - if (error.name === 'InvalidPDFException') { - console.warn(`🎯 PDF structure issue for ${file.name} - trying fallback with full file`); - // Return a placeholder or try with full file instead of chunk - try { - const fullArrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ - data: fullArrayBuffer, - disableAutoFetch: true, - disableStream: true, - verbosity: 0 // Reduce PDF.js warnings - }).promise; - - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext("2d"); - - if (!context) { - throw new Error('Could not get canvas context'); - } - - await page.render({ canvasContext: context, viewport }).promise; - const thumbnail = canvas.toDataURL(); - - pdf.destroy(); - console.log('🎯 Fallback PDF thumbnail generation succeeded for', file.name); - return thumbnail; - } catch (fallbackError) { - console.warn('🎯 Fallback thumbnail generation also failed for', file.name, fallbackError); - console.log('🎯 Using placeholder thumbnail for', file.name); - return generatePlaceholderThumbnail(file); - } - } else { - console.warn('🎯 Non-PDF error generating thumbnail for', file.name, error); - console.log('🎯 Using placeholder thumbnail for', file.name); - return generatePlaceholderThumbnail(file); - } - } - console.warn('🎯 Unknown error generating thumbnail for', file.name, error); - console.log('🎯 Using placeholder thumbnail for', file.name); - return generatePlaceholderThumbnail(file); - } + // All other files get placeholder + return generatePlaceholderThumbnail(file); } /** * Generate thumbnail and extract page count for a PDF file - * Returns both thumbnail and metadata in a single pass */ export async function generateThumbnailWithMetadata(file: File): Promise { - console.log(`🎯 generateThumbnailWithMetadata: Starting for ${file.name} (${file.type}, ${file.size} bytes)`); - // Non-PDF files default to 1 page if (!file.type.startsWith('application/pdf')) { - console.log('🎯 File is not a PDF, generating placeholder with pageCount=1:', file.name); const thumbnail = await generateThumbnailForFile(file); return { thumbnail, pageCount: 1 }; } - // Skip thumbnail generation for very large files to avoid memory issues - if (file.size >= 100 * 1024 * 1024) { // 100MB limit - console.log('🎯 Skipping processing for large PDF file:', file.name); + // Skip very large files + if (file.size >= 100 * 1024 * 1024) { const thumbnail = generatePlaceholderThumbnail(file); - return { thumbnail, pageCount: 1 }; // Default to 1 for large files + return { thumbnail, pageCount: 1 }; } - // Calculate quality scale based on file size const scale = calculateScaleFromFileSize(file.size); - console.log(`🎯 Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`); - + try { - // Read file chunk for processing - const chunkSize = 2 * 1024 * 1024; // 2MB - const chunk = file.slice(0, Math.min(chunkSize, file.size)); - const arrayBuffer = await chunk.arrayBuffer(); + const arrayBuffer = await file.arrayBuffer(); + const pdf = await getDocument({ data: arrayBuffer }).promise; - const pdf = await getDocument({ - data: arrayBuffer, - disableAutoFetch: true, - disableStream: true, - verbosity: 0 - }).promise; - const pageCount = pdf.numPages; - console.log(`🎯 PDF ${file.name} has ${pageCount} pages`); - - // Generate thumbnail for first page const page = await pdf.getPage(1); const viewport = page.getViewport({ scale }); const canvas = document.createElement("canvas"); @@ -337,57 +161,12 @@ export async function generateThumbnailWithMetadata(file: File): Promise