diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6bae4f3d4..54c5f7b19 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,8 +11,11 @@ "Bash(npm test:*)", "Bash(ls:*)", "Bash(npx tsc:*)", + "Bash(node:*)", + "Bash(npm run dev:*)", "Bash(sed:*)" ], - "deny": [] + "deny": [], + "defaultMode": "acceptEdits" } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 877b5c48a..f9ec204a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mantine/core": "^8.0.1", @@ -17,6 +18,7 @@ "@mui/icons-material": "^7.1.0", "@mui/material": "^7.1.0", "@tailwindcss/postcss": "^4.1.8", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -119,6 +121,17 @@ "is-potential-custom-element-name": "^1.0.1" } }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz", + "integrity": "sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2226,6 +2239,33 @@ "tailwindcss": "4.1.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2876,6 +2916,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6261,6 +6307,12 @@ ], "license": "MIT" }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8154a9a1c..ad945dbc2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "proxy": "http://localhost:8080", "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mantine/core": "^8.0.1", @@ -13,6 +14,7 @@ "@mui/icons-material": "^7.1.0", "@mui/material": "^7.1.0", "@tailwindcss/postcss": "^4.1.8", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2b00d7108..e09f874ac 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1967,7 +1967,9 @@ "uploadFiles": "Upload Files", "noFilesInStorage": "No files available in storage. Upload some files first.", "selectFromStorage": "Select from Storage", - "backToTools": "Back to Tools" + "backToTools": "Back to Tools", + "addFiles": "Add Files", + "dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse" }, "fileManager": { "title": "Upload PDF Files", diff --git a/frontend/public/thumbnailWorker.js b/frontend/public/thumbnailWorker.js deleted file mode 100644 index 2654ce6a4..000000000 --- a/frontend/public/thumbnailWorker.js +++ /dev/null @@ -1,157 +0,0 @@ -// Web Worker for parallel thumbnail generation -console.log('πŸ”§ Thumbnail worker starting up...'); - -let pdfJsLoaded = false; - -// Import PDF.js properly for worker context -try { - console.log('πŸ“¦ Loading PDF.js locally...'); - importScripts('/pdf.js'); - - // PDF.js exports to globalThis, check both self and globalThis - const pdfjsLib = self.pdfjsLib || globalThis.pdfjsLib; - - if (pdfjsLib) { - // Make it available on self for consistency - self.pdfjsLib = pdfjsLib; - - // Set up PDF.js worker - self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; - pdfJsLoaded = true; - console.log('βœ“ PDF.js loaded successfully from local files'); - console.log('βœ“ PDF.js version:', self.pdfjsLib.version || 'unknown'); - } else { - throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found'); - } -} catch (error) { - console.error('βœ— Failed to load local PDF.js:', error.message || error); - console.error('βœ— Available globals:', Object.keys(self).filter(key => key.includes('pdf'))); - pdfJsLoaded = false; -} - -// Log the final status -if (pdfJsLoaded) { - console.log('βœ… Thumbnail worker ready for PDF processing'); -} else { - console.log('❌ Thumbnail worker failed to initialize - PDF.js not available'); -} - -self.onmessage = async function(e) { - const { type, data, jobId } = e.data; - - try { - // Handle PING for worker health check - if (type === 'PING') { - console.log('πŸ“ Worker PING received, checking PDF.js status...'); - - // Check if PDF.js is loaded before responding - if (pdfJsLoaded && self.pdfjsLib) { - console.log('βœ“ Worker PONG - PDF.js ready'); - self.postMessage({ type: 'PONG', jobId }); - } else { - console.error('βœ— PDF.js not loaded - worker not ready'); - console.error('βœ— pdfJsLoaded:', pdfJsLoaded); - console.error('βœ— self.pdfjsLib:', !!self.pdfjsLib); - self.postMessage({ - type: 'ERROR', - jobId, - data: { error: 'PDF.js not loaded in worker' } - }); - } - return; - } - - if (type === 'GENERATE_THUMBNAILS') { - console.log('πŸ–ΌοΈ Starting thumbnail generation for', data.pageNumbers.length, 'pages'); - - if (!pdfJsLoaded || !self.pdfjsLib) { - const error = 'PDF.js not available in worker'; - console.error('βœ—', error); - throw new Error(error); - } - const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data; - - console.log('πŸ“„ Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes'); - // Load PDF in worker using imported PDF.js - const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise; - console.log('βœ“ PDF loaded, total pages:', pdf.numPages); - - const thumbnails = []; - - // Process pages in smaller batches for smoother UI - const batchSize = 3; // Process 3 pages at once for smoother UI - for (let i = 0; i < pageNumbers.length; i += batchSize) { - const batch = pageNumbers.slice(i, i + batchSize); - - const batchPromises = batch.map(async (pageNumber) => { - try { - console.log(`🎯 Processing page ${pageNumber}...`); - const page = await pdf.getPage(pageNumber); - const viewport = page.getViewport({ scale }); - console.log(`πŸ“ Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height); - - // Create OffscreenCanvas for better performance - const canvas = new OffscreenCanvas(viewport.width, viewport.height); - const context = canvas.getContext('2d'); - - if (!context) { - throw new Error('Failed to get 2D context from OffscreenCanvas'); - } - - await page.render({ canvasContext: context, viewport }).promise; - console.log(`βœ“ Page ${pageNumber} rendered`); - - // Convert to blob then to base64 (more efficient than toDataURL) - const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); - const arrayBuffer = await blob.arrayBuffer(); - const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); - const thumbnail = `data:image/jpeg;base64,${base64}`; - console.log(`βœ“ Page ${pageNumber} thumbnail generated (${base64.length} chars)`); - - return { pageNumber, thumbnail, success: true }; - } catch (error) { - console.error(`βœ— Failed to generate thumbnail for page ${pageNumber}:`, error.message || error); - return { pageNumber, error: error.message || String(error), success: false }; - } - }); - - const batchResults = await Promise.all(batchPromises); - thumbnails.push(...batchResults); - - // Send progress update - console.log(`πŸ“Š Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`); - self.postMessage({ - type: 'PROGRESS', - jobId, - data: { - completed: thumbnails.length, - total: pageNumbers.length, - thumbnails: batchResults.filter(r => r.success) - } - }); - - // Small delay between batches to keep UI smooth - if (i + batchSize < pageNumbers.length) { - console.log(`⏸️ Worker: Pausing 100ms before next batch (${i + batchSize}/${pageNumbers.length})`); - await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling - } - } - - // Clean up - pdf.destroy(); - - self.postMessage({ - type: 'COMPLETE', - jobId, - data: { thumbnails: thumbnails.filter(r => r.success) } - }); - - } - } catch (error) { - self.postMessage({ - type: 'ERROR', - jobId, - data: { error: error.message } - }); - } -}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d2aec8242..e628dc4de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React, { Suspense } from 'react'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { FileContextProvider } from './contexts/FileContext'; +import { NavigationProvider } from './contexts/NavigationContext'; import { FilesModalProvider } from './contexts/FilesModalContext'; import HomePage from './pages/HomePage'; @@ -27,9 +28,11 @@ export default function App() { }> - - - + + + + + diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts index 4e5572234..92a9c9a73 100644 --- a/frontend/src/commands/pageCommands.ts +++ b/frontend/src/commands/pageCommands.ts @@ -48,7 +48,11 @@ export class RotatePagesCommand extends PageCommand { return page; }); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } get description(): string { @@ -148,7 +152,11 @@ export class MovePagesCommand extends PageCommand { pageNumber: index + 1 })); - this.setPdfDocument({ ...this.pdfDocument, pages: newPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: newPages, + totalPages: newPages.length + }); } get description(): string { @@ -185,7 +193,11 @@ export class ReorderPageCommand extends PageCommand { pageNumber: index + 1 })); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } get description(): string { @@ -224,7 +236,11 @@ export class ToggleSplitCommand extends PageCommand { return page; }); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } undo(): void { @@ -236,7 +252,11 @@ export class ToggleSplitCommand extends PageCommand { return page; }); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } get description(): string { diff --git a/frontend/src/components/FileCard.standalone.tsx b/frontend/src/components/FileCard.standalone.tsx deleted file mode 100644 index 4d140689b..000000000 --- a/frontend/src/components/FileCard.standalone.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React from "react"; -import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; -import StorageIcon from "@mui/icons-material/Storage"; - -import { FileWithUrl } from "../types/file"; -import { getFileSize, getFileDate } from "../utils/fileUtils"; -import { useIndexedDBThumbnail } from "../hooks/useIndexedDBThumbnail"; - -interface FileCardProps { - file: FileWithUrl; - onRemove: () => void; - onDoubleClick?: () => void; -} - -const FileCard: React.FC = ({ file, onRemove, onDoubleClick }) => { - const { t } = useTranslation(); - const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file); - - return ( - - - - {thumb ? ( - PDF thumbnail - ) : isGenerating ? ( -
-
- Generating... -
- ) : ( -
- 100 * 1024 * 1024 ? "orange" : "red"} - size={60} - radius="sm" - style={{ display: "flex", alignItems: "center", justifyContent: "center" }} - > - - - {file.size > 100 * 1024 * 1024 && ( - Large File - )} -
- )} - - - - {file.name} - - - - - {getFileSize(file)} - - - {getFileDate(file)} - - {file.storedInIndexedDB && ( - } - > - DB - - )} - - - - - - ); -}; - -export default FileCard; \ No newline at end of file diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 5f6af568b..1c327cefa 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -1,9 +1,10 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Modal } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { FileWithUrl } from '../types/file'; +import { FileMetadata } from '../types/file'; import { useFileManager } from '../hooks/useFileManager'; import { useFilesModalContext } from '../contexts/FilesModalContext'; +import { createFileId } from '../types/fileContext'; import { Tool } from '../types/tool'; import MobileLayout from './fileManager/MobileLayout'; import DesktopLayout from './fileManager/DesktopLayout'; @@ -15,13 +16,19 @@ interface FileManagerProps { } const FileManager: React.FC = ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext(); - const [recentFiles, setRecentFiles] = useState([]); + const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext(); + const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); + // Wrapper for storeFile that generates UUID + const storeFileWithId = useCallback(async (file: File) => { + const fileId = createFileId(); // Generate UUID for storage + return await storeFile(file, fileId); + }, [storeFile]); + // File management handlers const isFileSupported = useCallback((fileName: string) => { if (!selectedTool?.supportedFormats) return true; @@ -34,18 +41,21 @@ const FileManager: React.FC = ({ selectedTool }) => { setRecentFiles(files); }, [loadRecentFiles]); - const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => { + const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { try { - const fileObjects = await Promise.all( - files.map(async (fileWithUrl) => { - return await convertToFile(fileWithUrl); - }) + // Use stored files flow that preserves original IDs + const filesWithMetadata = await Promise.all( + files.map(async (metadata) => ({ + file: await convertToFile(metadata), + originalId: metadata.id, + metadata + })) ); - onFilesSelect(fileObjects); + onStoredFilesSelect(filesWithMetadata); } catch (error) { console.error('Failed to process selected files:', error); } - }, [convertToFile, onFilesSelect]); + }, [convertToFile, onStoredFilesSelect]); const handleNewFileUpload = useCallback(async (files: File[]) => { if (files.length > 0) { @@ -82,14 +92,11 @@ const FileManager: React.FC = ({ selectedTool }) => { // Cleanup any blob URLs when component unmounts useEffect(() => { return () => { - // Clean up blob URLs from recent files - recentFiles.forEach(file => { - if (file.url && file.url.startsWith('blob:')) { - URL.revokeObjectURL(file.url); - } - }); + // FileMetadata doesn't have blob URLs, so no cleanup needed + // Blob URLs are managed by FileContext and tool operations + console.log('FileManager unmounting - FileContext handles blob URL cleanup'); }; - }, [recentFiles]); + }, []); // Modal size constants for consistent scaling const modalHeight = '80vh'; @@ -130,7 +137,7 @@ const FileManager: React.FC = ({ selectedTool }) => { onDrop={handleNewFileUpload} onDragEnter={() => setIsDragging(true)} onDragLeave={() => setIsDragging(false)} - accept={["*/*"] as any} + accept={{}} multiple={true} activateOnClick={false} style={{ @@ -147,12 +154,12 @@ const FileManager: React.FC = ({ selectedTool }) => { {isMobile ? : } diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index c45e7e902..c93e78670 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container, Stack, Group @@ -6,8 +6,8 @@ import { import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { useFileContext } from '../../contexts/FileContext'; -import { useFileSelection } from '../../contexts/FileSelectionContext'; +import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; +import { useNavigationActions } from '../../contexts/NavigationContext'; import { FileOperation } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; @@ -15,19 +15,9 @@ import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; import styles from '../pageEditor/PageEditor.module.css'; import FileThumbnail from '../pageEditor/FileThumbnail'; -import DragDropGrid from '../pageEditor/DragDropGrid'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; -interface FileItem { - id: string; - name: string; - pageCount: number; - thumbnail: string; - size: number; - file: File; - splitBefore?: boolean; -} interface FileEditorProps { onOpenPageEditor?: (file: File) => void; @@ -54,33 +44,25 @@ const FileEditor = ({ return extension ? supportedExtensions.includes(extension) : false; }, [supportedExtensions]); - // Get file context - const fileContext = useFileContext(); - const { - activeFiles, - processedFiles, - selectedFileIds, - setSelectedFiles: setContextSelectedFiles, - isProcessing, - addFiles, - removeFiles, - setCurrentView, - recordOperation, - markOperationApplied - } = fileContext; - + // Use optimized FileContext hooks + const { state, selectors } = useFileState(); + const { addFiles, removeFiles, reorderFiles } = useFileManagement(); + + // Extract needed values from state (memoized to prevent infinite loops) + const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); + const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); + const selectedFileIds = state.ui.selectedFileIds; + const isProcessing = state.ui.isProcessing; + + // Get the real context actions + const { actions } = useFileActions(); + const { actions: navActions } = useNavigationActions(); + // Get file selection context - const { - selectedFiles: toolSelectedFiles, - setSelectedFiles: setToolSelectedFiles, - maxFiles, - isToolMode - } = useFileSelection(); + const { setSelectedFiles } = useFileSelection(); - const [files, setFiles] = useState([]); const [status, setStatus] = useState(null); const [error, setError] = useState(null); - const [localLoading, setLocalLoading] = useState(false); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode @@ -89,13 +71,7 @@ const FileEditor = ({ setSelectionMode(true); } }, [toolMode]); - const [draggedFile, setDraggedFile] = useState(null); - const [dropTarget, setDropTarget] = useState(null); - const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null); - const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); - const [isAnimating, setIsAnimating] = useState(false); const [showFilePickerModal, setShowFilePickerModal] = useState(false); - const [conversionProgress, setConversionProgress] = useState(0); const [zipExtractionProgress, setZipExtractionProgress] = useState<{ isExtracting: boolean; currentFile: string; @@ -109,115 +85,30 @@ const FileEditor = ({ extractedCount: 0, totalFiles: 0 }); - const fileRefs = useRef>(new Map()); - const lastActiveFilesRef = useRef([]); - const lastProcessedFilesRef = useRef(0); - // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; + + // Create refs for frequently changing values to stabilize callbacks + const contextSelectedIdsRef = useRef([]); + contextSelectedIdsRef.current = contextSelectedIds; - // Map context selections to local file IDs for UI display - const localSelectedIds = files - .filter(file => { - const fileId = (file.file as any).id || file.name; - return contextSelectedIds.includes(fileId); - }) - .map(file => file.id); - - // Convert shared files to FileEditor format - const convertToFileItem = useCallback(async (sharedFile: any): Promise => { - // Generate thumbnail if not already available - const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile); + // Use activeFileRecords directly - no conversion needed + const localSelectedIds = contextSelectedIds; + // Helper to convert FileRecord to FileThumbnail format + const recordToFileItem = useCallback((record: any) => { + const file = selectors.getFile(record.id); + if (!file) return null; + return { - id: sharedFile.id || `file-${Date.now()}-${Math.random()}`, - name: (sharedFile.file?.name || sharedFile.name || 'unknown'), - pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now - thumbnail, - size: sharedFile.file?.size || sharedFile.size || 0, - file: sharedFile.file || sharedFile, + id: record.id, + name: file.name, + pageCount: record.processedFile?.totalPages || 1, + thumbnail: record.thumbnailUrl || '', + size: file.size, + file: file }; - }, []); - - // Convert activeFiles to FileItem format using context (async to avoid blocking) - useEffect(() => { - // Check if the actual content has changed, not just references - const currentActiveFileNames = activeFiles.map(f => f.name); - const currentProcessedFilesSize = processedFiles.size; - - const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current); - const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current; - - if (!activeFilesChanged && !processedFilesChanged) { - return; - } - - // Update refs - lastActiveFilesRef.current = currentActiveFileNames; - lastProcessedFilesRef.current = currentProcessedFilesSize; - - const convertActiveFiles = async () => { - - if (activeFiles.length > 0) { - setLocalLoading(true); - try { - // Process files in chunks to avoid blocking UI - const convertedFiles: FileItem[] = []; - - for (let i = 0; i < activeFiles.length; i++) { - const file = activeFiles[i]; - - // Try to get thumbnail from processed file first - const processedFile = processedFiles.get(file); - let thumbnail = processedFile?.pages?.[0]?.thumbnail; - - // If no thumbnail from processed file, try to generate one - if (!thumbnail) { - try { - thumbnail = await generateThumbnailForFile(file); - } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); - thumbnail = undefined; // Use placeholder - } - } - - const convertedFile = { - id: `file-${Date.now()}-${Math.random()}`, - name: file.name, - pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1, - thumbnail: thumbnail || '', - size: file.size, - file, - }; - - convertedFiles.push(convertedFile); - - // Update progress - setConversionProgress(((i + 1) / activeFiles.length) * 100); - - // Yield to main thread between files - if (i < activeFiles.length - 1) { - await new Promise(resolve => requestAnimationFrame(resolve)); - } - } - - - setFiles(convertedFiles); - } catch (err) { - console.error('Error converting active files:', err); - } finally { - setLocalLoading(false); - setConversionProgress(0); - } - } else { - setFiles([]); - setLocalLoading(false); - setConversionProgress(0); - } - }; - - convertActiveFiles(); - }, [activeFiles, processedFiles]); + }, [selectors]); // Process uploaded files using context @@ -289,10 +180,7 @@ const FileEditor = ({ } } }; - - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); - + if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } @@ -301,7 +189,6 @@ const FileEditor = ({ } } else { // ZIP doesn't contain PDFs or is invalid - treat as regular file - console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`); allExtractedFiles.push(file); } } catch (zipError) { @@ -315,7 +202,6 @@ const FileEditor = ({ }); } } else { - console.log(`Adding none PDF file: ${file.name} (${file.type})`); allExtractedFiles.push(file); } } @@ -344,9 +230,6 @@ const FileEditor = ({ } } }; - - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); } // Add files to context (they will be processed automatically) @@ -357,7 +240,7 @@ const FileEditor = ({ const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; setError(errorMessage); console.error('File processing error:', err); - + // Reset extraction progress on error setZipExtractionProgress({ isExtracting: false, @@ -367,220 +250,137 @@ const FileEditor = ({ totalFiles: 0 }); } - }, [addFiles, recordOperation, markOperationApplied]); + }, [addFiles]); const selectAll = useCallback(() => { - setContextSelectedFiles(files.map(f => (f.file as any).id || f.name)); - }, [files, setContextSelectedFiles]); + setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly + }, [activeFileRecords, setSelectedFiles]); - const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); + const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]); const closeAllFiles = useCallback(() => { - if (activeFiles.length === 0) return; - - // Record close all operation for each file - activeFiles.forEach(file => { - const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { - id: operationId, - type: 'remove', - timestamp: Date.now(), - fileIds: [file.name], - status: 'pending', - metadata: { - originalFileName: file.name, - fileSize: file.size, - parameters: { - action: 'close_all', - reason: 'user_request' - } - } - }; - - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); - }); + if (activeFileRecords.length === 0) return; // Remove all files from context but keep in storage - removeFiles(activeFiles.map(f => (f as any).id || f.name), false); - + const allFileIds = activeFileRecords.map(record => record.id); + removeFiles(allFileIds, false); // false = keep in storage + // Clear selections - setContextSelectedFiles([]); - }, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); + setSelectedFiles([]); + }, [activeFileRecords, removeFiles, setSelectedFiles]); const toggleFile = useCallback((fileId: string) => { - const targetFile = files.find(f => f.id === fileId); - if (!targetFile) return; + const currentSelectedIds = contextSelectedIdsRef.current; + + const targetRecord = activeFileRecords.find(r => r.id === fileId); + if (!targetRecord) return; - const contextFileId = (targetFile.file as any).id || targetFile.name; - const isSelected = contextSelectedIds.includes(contextFileId); + const contextFileId = fileId; // No need to create a new ID + const isSelected = currentSelectedIds.includes(contextFileId); let newSelection: string[]; if (isSelected) { // Remove file from selection - newSelection = contextSelectedIds.filter(id => id !== contextFileId); + newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection - if (maxFiles === 1) { + // In tool mode, typically allow multiple files unless specified otherwise + const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools + + if (maxAllowed === 1) { newSelection = [contextFileId]; } else { // Check if we've hit the selection limit - if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) { - setStatus(`Maximum ${maxFiles} files can be selected`); + if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { + setStatus(`Maximum ${maxAllowed} files can be selected`); return; } - newSelection = [...contextSelectedIds, contextFileId]; + newSelection = [...currentSelectedIds, contextFileId]; } } - // Update context - setContextSelectedFiles(newSelection); - - // Update tool selection context if in tool mode - if (isToolMode || toolMode) { - const selectedFiles = files - .filter(f => { - const fId = (f.file as any).id || f.name; - return newSelection.includes(fId); - }) - .map(f => f.file); - setToolSelectedFiles(selectedFiles); - } - }, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]); + // Update context (this automatically updates tool selection since they use the same action) + setSelectedFiles(newSelection); + }, [setSelectedFiles, toolMode, setStatus, activeFileRecords]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { - setContextSelectedFiles([]); + setSelectedFiles([]); } return newMode; }); - }, [setContextSelectedFiles]); + }, [setSelectedFiles]); - - // Drag and drop handlers - const handleDragStart = useCallback((fileId: string) => { - setDraggedFile(fileId); - - if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) { - setMultiFileDrag({ - fileIds: localSelectedIds, - count: localSelectedIds.length - }); - } else { - setMultiFileDrag(null); - } - }, [selectionMode, localSelectedIds]); - - const handleDragEnd = useCallback(() => { - setDraggedFile(null); - setDropTarget(null); - setMultiFileDrag(null); - setDragPosition(null); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - - if (!draggedFile) return; - - if (multiFileDrag) { - setDragPosition({ x: e.clientX, y: e.clientY }); - } - - const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); - if (!elementUnderCursor) return; - - const fileContainer = elementUnderCursor.closest('[data-file-id]'); - if (fileContainer) { - const fileId = fileContainer.getAttribute('data-file-id'); - if (fileId && fileId !== draggedFile) { - setDropTarget(fileId); - return; - } - } - - const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); - if (endZone) { - setDropTarget('end'); + // File reordering handler for drag and drop + const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => { + const currentIds = activeFileRecords.map(r => r.id); + + // Find indices + const sourceIndex = currentIds.findIndex(id => id === sourceFileId); + const targetIndex = currentIds.findIndex(id => id === targetFileId); + + if (sourceIndex === -1 || targetIndex === -1) { + console.warn('Could not find source or target file for reordering'); return; } - setDropTarget(null); - }, [draggedFile, multiFileDrag]); + // Handle multi-file selection reordering + const filesToMove = selectedFileIds.length > 1 + ? selectedFileIds.filter(id => currentIds.includes(id)) + : [sourceFileId]; - const handleDragEnter = useCallback((fileId: string) => { - if (draggedFile && fileId !== draggedFile) { - setDropTarget(fileId); - } - }, [draggedFile]); - - const handleDragLeave = useCallback(() => { - // Let dragover handle this - }, []); - - const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => { - e.preventDefault(); - if (!draggedFile || draggedFile === targetFileId) return; - - let targetIndex: number; - if (targetFileId === 'end') { - targetIndex = files.length; - } else { - targetIndex = files.findIndex(f => f.id === targetFileId); - if (targetIndex === -1) return; - } - - const filesToMove = selectionMode && localSelectedIds.includes(draggedFile) - ? localSelectedIds - : [draggedFile]; - - // Update the local files state and sync with activeFiles - setFiles(prev => { - const newFiles = [...prev]; - const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean); - - // Remove moved files - filesToMove.forEach(id => { - const index = newFiles.findIndex(f => f.id === id); - if (index !== -1) newFiles.splice(index, 1); - }); - - // Insert at target position - newFiles.splice(targetIndex, 0, ...movedFiles); - - // TODO: Update context with reordered files (need to implement file reordering in context) - // For now, just return the reordered local state - return newFiles; + // Create new order + const newOrder = [...currentIds]; + + // Remove files to move from their current positions (in reverse order to maintain indices) + const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id)) + .sort((a, b) => b - a); // Sort descending + + sourceIndices.forEach(index => { + newOrder.splice(index, 1); }); - const moveCount = multiFileDrag ? multiFileDrag.count : 1; - setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - - }, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]); - - const handleEndZoneDragEnter = useCallback(() => { - if (draggedFile) { - setDropTarget('end'); + // Calculate insertion index after removals + let insertIndex = newOrder.findIndex(id => id === targetFileId); + if (insertIndex !== -1) { + // Determine if moving forward or backward + const isMovingForward = sourceIndex < targetIndex; + if (isMovingForward) { + // Moving forward: insert after target + insertIndex += 1; + } else { + // Moving backward: insert before target (insertIndex already correct) + } + } else { + // Target was moved, insert at end + insertIndex = newOrder.length; } - }, [draggedFile]); + + // Insert files at the calculated position + newOrder.splice(insertIndex, 0, ...filesToMove); + + // Update file order + reorderFiles(newOrder); + + // Update status + const moveCount = filesToMove.length; + setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); + }, [activeFileRecords, reorderFiles, setStatus]); + + // File operations using context const handleDeleteFile = useCallback((fileId: string) => { - console.log('handleDeleteFile called with fileId:', fileId); - const file = files.find(f => f.id === fileId); - console.log('Found file:', file); - - if (file) { - console.log('Attempting to remove file:', file.name); - console.log('Actual file object:', file.file); - console.log('Actual file.file.name:', file.file.name); + const record = activeFileRecords.find(r => r.id === fileId); + const file = record ? selectors.getFile(record.id) : null; + if (record && file) { // Record close operation - const fileName = file.file.name; - const fileId = (file.file as any).id || fileName; + const fileName = file.name; + const contextFileId = record.id; const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, @@ -590,75 +390,62 @@ const FileEditor = ({ status: 'pending', metadata: { originalFileName: fileName, - fileSize: file.size, + fileSize: record.size, parameters: { action: 'close', reason: 'user_request' } } }; - - recordOperation(fileName, operation); - + // Remove file from context but keep in storage (close, don't delete) - console.log('Calling removeFiles with:', [fileId]); - removeFiles([fileId], false); + removeFiles([contextFileId], false); // Remove from context selections - const newSelection = contextSelectedIds.filter(id => id !== fileId); - setContextSelectedFiles(newSelection); - // Mark operation as applied - markOperationApplied(fileName, operationId); - } else { - console.log('File not found for fileId:', fileId); + const currentSelected = selectedFileIds.filter(id => id !== contextFileId); + setSelectedFiles(currentSelected); } - }, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); + }, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); const handleViewFile = useCallback((fileId: string) => { - const file = files.find(f => f.id === fileId); - if (file) { - // Set the file as selected in context and switch to page editor view - const contextFileId = (file.file as any).id || file.name; - setContextSelectedFiles([contextFileId]); - setCurrentView('pageEditor'); - onOpenPageEditor?.(file.file); + const record = activeFileRecords.find(r => r.id === fileId); + if (record) { + // Set the file as selected in context and switch to viewer for preview + setSelectedFiles([fileId]); + navActions.setMode('viewer'); } - }, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]); + }, [activeFileRecords, setSelectedFiles, navActions.setMode]); const handleMergeFromHere = useCallback((fileId: string) => { - const startIndex = files.findIndex(f => f.id === fileId); + const startIndex = activeFileRecords.findIndex(r => r.id === fileId); if (startIndex === -1) return; - const filesToMerge = files.slice(startIndex).map(f => f.file); + const recordsToMerge = activeFileRecords.slice(startIndex); + const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[]; if (onMergeFiles) { onMergeFiles(filesToMerge); } - }, [files, onMergeFiles]); + }, [activeFileRecords, selectors, onMergeFiles]); const handleSplitFile = useCallback((fileId: string) => { - const file = files.find(f => f.id === fileId); + const file = selectors.getFile(fileId); if (file && onOpenPageEditor) { - onOpenPageEditor(file.file); + onOpenPageEditor(file); } - }, [files, onOpenPageEditor]); + }, [selectors, onOpenPageEditor]); const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { if (selectedFiles.length === 0) return; - setLocalLoading(true); try { - const convertedFiles = await Promise.all( - selectedFiles.map(convertToFileItem) - ); - setFiles(prev => [...prev, ...convertedFiles]); + // Use FileContext to handle loading stored files + // The files are already in FileContext, just need to add them to active files setStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); setError('Failed to load some files from storage'); - } finally { - setLocalLoading(false); } - }, [convertToFileItem]); + }, []); return ( @@ -680,10 +467,14 @@ const FileEditor = ({ - {showBulkActions && !toolMode && ( + {toolMode && ( <> + + )} + {showBulkActions && !toolMode && ( + <> @@ -692,7 +483,7 @@ const FileEditor = ({ - {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( + {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
πŸ“ @@ -700,7 +491,7 @@ const FileEditor = ({ Upload PDF files, ZIP archives, or load from storage to get started
- ) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? ( + ) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( @@ -734,88 +525,42 @@ const FileEditor = ({ )} - {/* Processing indicator */} - {localLoading && ( - - - Loading files... - {Math.round(conversionProgress)}% - -
-
-
- - )} ) : ( - ( - - )} - renderSplitMarker={(file, index) => ( -
- )} - /> +
+ {activeFileRecords.map((record, index) => { + const fileItem = recordToFileItem(record); + if (!fileItem) return null; + + return ( + + ); + })} +
)} diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx index 7f7c410b7..b1b5f0d24 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; import { getFileSize } from '../../utils/fileUtils'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface CompactFileDetailsProps { - currentFile: FileWithUrl | null; + currentFile: FileMetadata | null; thumbnail: string | null; - selectedFiles: FileWithUrl[]; + selectedFiles: FileMetadata[]; currentFileIndex: number; numberOfFiles: number; isAnimating: boolean; diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx index 7e69dd2ed..f8cc84cb8 100644 --- a/frontend/src/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface FileInfoCardProps { - currentFile: FileWithUrl | null; + currentFile: FileMetadata | null; modalHeight: string; } diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx index fd9357a94..bb376765b 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -52,9 +52,9 @@ const FileListArea: React.FC = ({ ) : ( filteredFiles.map((file, index) => ( onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 4b0e408d1..b04f9bc41 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -1,14 +1,14 @@ import React, { useState } from 'react'; -import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu } from '@mantine/core'; +import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DeleteIcon from '@mui/icons-material/Delete'; import DownloadIcon from '@mui/icons-material/Download'; import { useTranslation } from 'react-i18next'; import { getFileSize, getFileDate } from '../../utils/fileUtils'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface FileListItemProps { - file: FileWithUrl; + file: FileMetadata; isSelected: boolean; isSupported: boolean; onSelect: (shiftKey?: boolean) => void; @@ -70,7 +70,14 @@ const FileListItem: React.FC = ({ - {file.name} + + {file.name} + {file.isDraft && ( + + DRAFT + + )} + {getFileSize(file)} β€’ {getFileDate(file)} diff --git a/frontend/src/components/history/FileOperationHistory.tsx b/frontend/src/components/history/FileOperationHistory.tsx index 93b9cf015..60a4a7b0c 100644 --- a/frontend/src/components/history/FileOperationHistory.tsx +++ b/frontend/src/components/history/FileOperationHistory.tsx @@ -11,7 +11,7 @@ import { Code, Divider } from '@mantine/core'; -import { useFileContext } from '../../contexts/FileContext'; +// FileContext no longer needed - these were stub functions anyway import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext'; import { PageOperation } from '../../types/pageEditor'; @@ -26,11 +26,13 @@ const FileOperationHistory: React.FC = ({ showOnlyApplied = false, maxHeight = 400 }) => { - const { getFileHistory, getAppliedOperations } = useFileContext(); + // These were stub functions in the old context - replace with empty stubs + const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() }); + const getAppliedOperations = (fileId: string) => []; const history = getFileHistory(fileId); const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || []; - const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[]; + const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[]; const formatTimestamp = (timestamp: number) => { return new Date(timestamp).toLocaleString(); diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 732b37d7b..fc41d2480 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useFileHandler } from '../../hooks/useFileHandler'; -import { useFileContext } from '../../contexts/FileContext'; +import { useFileState, useFileActions } from '../../contexts/FileContext'; +import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; import TopControls from '../shared/TopControls'; import FileEditor from '../fileEditor/FileEditor'; @@ -20,7 +21,12 @@ export default function Workbench() { const { isRainbowMode } = useRainbowThemeContext(); // Use context-based hooks to eliminate all prop drilling - const { activeFiles, currentView, setCurrentView } = useFileContext(); + const { state } = useFileState(); + const { actions } = useFileActions(); + const { currentMode: currentView } = useNavigationState(); + const { actions: navActions } = useNavigationActions(); + const setCurrentView = navActions.setMode; + const activeFiles = state.files.ids; const { previewFile, pageEditorFunctions, @@ -47,12 +53,12 @@ export default function Workbench() { handleToolSelect('convert'); sessionStorage.removeItem('previousMode'); } else { - setCurrentView('fileEditor' as any); + setCurrentView('fileEditor'); } }; const renderMainContent = () => { - if (!activeFiles[0]) { + if (activeFiles.length === 0) { return ( @@ -69,11 +75,11 @@ export default function Workbench() { supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} {...(!selectedToolKey && { onOpenPageEditor: (file) => { - setCurrentView("pageEditor" as any); + setCurrentView("pageEditor"); }, onMergeFiles: (filesToMerge) => { filesToMerge.forEach(addToActiveFiles); - setCurrentView("viewer" as any); + setCurrentView("viewer"); } })} /> @@ -142,7 +148,7 @@ export default function Workbench() { {/* Top Controls */} diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 3639f74d9..5829d0375 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -1,5 +1,7 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Box } from '@mantine/core'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import styles from './PageEditor.module.css'; interface DragDropItem { @@ -12,19 +14,9 @@ interface DragDropGridProps { selectedItems: number[]; selectionMode: boolean; isAnimating: boolean; - onDragStart: (pageNumber: number) => void; - onDragEnd: () => void; - onDragOver: (e: React.DragEvent) => void; - onDragEnter: (pageNumber: number) => void; - onDragLeave: () => void; - onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void; - onEndZoneDragEnter: () => void; + onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; renderItem: (item: T, index: number, refs: React.MutableRefObject>) => React.ReactNode; renderSplitMarker?: (item: T, index: number) => React.ReactNode; - draggedItem: number | null; - dropTarget: number | 'end' | null; - multiItemDrag: {pageNumbers: number[], count: number} | null; - dragPosition: {x: number, y: number} | null; } const DragDropGrid = ({ @@ -32,104 +24,129 @@ const DragDropGrid = ({ selectedItems, selectionMode, isAnimating, - onDragStart, - onDragEnd, - onDragOver, - onDragEnter, - onDragLeave, - onDrop, - onEndZoneDragEnter, + onReorderPages, renderItem, renderSplitMarker, - draggedItem, - dropTarget, - multiItemDrag, - dragPosition, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); - - // Global drag cleanup + const containerRef = useRef(null); + + // Responsive grid configuration + const [itemsPerRow, setItemsPerRow] = useState(4); + const ITEM_WIDTH = 320; // 20rem (page width) + const ITEM_GAP = 24; // 1.5rem gap between items + const ITEM_HEIGHT = 340; // 20rem + gap + const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents + + // Calculate items per row based on container width + const calculateItemsPerRow = useCallback(() => { + if (!containerRef.current) return 4; // Default fallback + + const containerWidth = containerRef.current.offsetWidth; + if (containerWidth === 0) return 4; // Container not measured yet + + // Calculate how many items fit: (width - gap) / (itemWidth + gap) + const availableWidth = containerWidth - ITEM_GAP; // Account for first gap + const itemWithGap = ITEM_WIDTH + ITEM_GAP; + const calculated = Math.floor(availableWidth / itemWithGap); + + return Math.max(1, calculated); // At least 1 item per row + }, []); + + // Update items per row when container resizes useEffect(() => { - const handleGlobalDragEnd = () => { - onDragEnd(); + const updateLayout = () => { + const newItemsPerRow = calculateItemsPerRow(); + setItemsPerRow(newItemsPerRow); }; - - const handleGlobalDrop = (e: DragEvent) => { - e.preventDefault(); - }; - - if (draggedItem) { - document.addEventListener('dragend', handleGlobalDragEnd); - document.addEventListener('drop', handleGlobalDrop); + + // Initial calculation + updateLayout(); + + // Listen for window resize + window.addEventListener('resize', updateLayout); + + // Use ResizeObserver for container size changes + const resizeObserver = new ResizeObserver(updateLayout); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); } - + return () => { - document.removeEventListener('dragend', handleGlobalDragEnd); - document.removeEventListener('drop', handleGlobalDrop); + window.removeEventListener('resize', updateLayout); + resizeObserver.disconnect(); }; - }, [draggedItem, onDragEnd]); + }, [calculateItemsPerRow]); + + // Virtualization with react-virtual library + const rowVirtualizer = useVirtualizer({ + count: Math.ceil(items.length / itemsPerRow), + getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element, + estimateSize: () => ITEM_HEIGHT, + overscan: OVERSCAN, + }); + + return ( - +
- {items.map((item, index) => ( - - {/* Split marker */} - {renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)} - - {/* Item */} - {renderItem(item, index, itemRefs)} - - ))} - - {/* End drop zone */} -
-
onDrop(e, 'end')} - > -
- Drop here to
move to end + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * itemsPerRow; + const endIndex = Math.min(startIndex + itemsPerRow, items.length); + const rowItems = items.slice(startIndex, endIndex); + + return ( +
+
+ {rowItems.map((item, itemIndex) => { + const actualIndex = startIndex + itemIndex; + return ( + + {/* Split marker */} + {renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)} + {/* Item */} + {renderItem(item, actualIndex, itemRefs)} + + ); + })} + +
-
-
+ ); + })}
- - {/* Multi-item drag indicator */} - {multiItemDrag && dragPosition && ( -
- {multiItemDrag.count} items -
- )} ); }; diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index c328a350d..d84eb2a16 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -1,14 +1,12 @@ -import React, { useState } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import CloseIcon from '@mui/icons-material/Close'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import HistoryIcon from '@mui/icons-material/History'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import styles from './PageEditor.module.css'; -import FileOperationHistory from '../history/FileOperationHistory'; import { useFileContext } from '../../contexts/FileContext'; interface FileItem { @@ -26,20 +24,11 @@ interface FileThumbnailProps { totalFiles: number; selectedFiles: string[]; selectionMode: boolean; - draggedFile: string | null; - dropTarget: string | null; - isAnimating: boolean; - fileRefs: React.MutableRefObject>; - onDragStart: (fileId: string) => void; - onDragEnd: () => void; - onDragOver: (e: React.DragEvent) => void; - onDragEnter: (fileId: string) => void; - onDragLeave: () => void; - onDrop: (e: React.DragEvent, fileId: string) => void; onToggleFile: (fileId: string) => void; onDeleteFile: (fileId: string) => void; onViewFile: (fileId: string) => void; onSetStatus: (status: string) => void; + onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; toolMode?: boolean; isSupported?: boolean; } @@ -50,26 +39,20 @@ const FileThumbnail = ({ totalFiles, selectedFiles, selectionMode, - draggedFile, - dropTarget, - isAnimating, - fileRefs, - onDragStart, - onDragEnd, - onDragOver, - onDragEnter, - onDragLeave, - onDrop, onToggleFile, onDeleteFile, onViewFile, onSetStatus, + onReorderFiles, toolMode = false, isSupported = true, }: FileThumbnailProps) => { const { t } = useTranslation(); - const [showHistory, setShowHistory] = useState(false); const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + + // Drag and drop state + const [isDragging, setIsDragging] = useState(false); + const dragElementRef = useRef(null); // Find the actual File object that corresponds to this FileItem const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size); @@ -82,15 +65,57 @@ const FileThumbnail = ({ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; + // Setup drag and drop using @atlaskit/pragmatic-drag-and-drop + const fileElementRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return; + + dragElementRef.current = element; + + const dragCleanup = draggable({ + element, + getInitialData: () => ({ + type: 'file', + fileId: file.id, + fileName: file.name, + selectedFiles: [file.id] // Always drag only this file, ignore selection state + }), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + } + }); + + const dropCleanup = dropTargetForElements({ + element, + getData: () => ({ + type: 'file', + fileId: file.id + }), + canDrop: ({ source }) => { + const sourceData = source.data; + return sourceData.type === 'file' && sourceData.fileId !== file.id; + }, + onDrop: ({ source }) => { + const sourceData = source.data; + if (sourceData.type === 'file' && onReorderFiles) { + const sourceFileId = sourceData.fileId as string; + const selectedFileIds = sourceData.selectedFiles as string[]; + onReorderFiles(sourceFileId, file.id, selectedFileIds); + } + } + }); + + return () => { + dragCleanup(); + dropCleanup(); + }; + }, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]); + return (
{ - if (el) { - fileRefs.current.set(file.id, el); - } else { - fileRefs.current.delete(file.id); - } - }} + ref={fileElementRef} data-file-id={file.id} data-testid="file-thumbnail" className={` @@ -109,26 +134,12 @@ const FileThumbnail = ({ ${selectionMode ? 'bg-white hover:bg-gray-50' : 'bg-white hover:bg-gray-50'} - ${draggedFile === file.id ? 'opacity-50 scale-95' : ''} + ${isDragging ? 'opacity-50 scale-95' : ''} `} style={{ - transform: (() => { - if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) { - return 'translateX(20px)'; - } - return 'translateX(0)'; - })(), - transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out', - opacity: isSupported ? 1 : 0.5, + opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5, filter: isSupported ? 'none' : 'grayscale(50%)' }} - draggable - onDragStart={() => onDragStart(file.id)} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={() => onDragEnter(file.id)} - onDragLeave={onDragLeave} - onDrop={(e) => onDrop(e, file.id)} > {selectionMode && (
{ + // Hide broken image if blob URL was revoked + const img = e.target as HTMLImageElement; + img.style.display = 'none'; + }} style={{ maxWidth: '100%', maxHeight: '100%', @@ -196,20 +213,22 @@ const FileThumbnail = ({ />
- {/* Page count badge */} - - {file.pageCount} pages - + {/* Page count badge - only show for PDFs */} + {file.pageCount > 0 && ( + + {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} + + )} {/* Unsupported badge */} {!isSupported && ( @@ -273,40 +292,6 @@ const FileThumbnail = ({ whiteSpace: 'nowrap' }} > - {!toolMode && isSupported && ( - <> - - { - e.stopPropagation(); - onViewFile(file.id); - onSetStatus(`Opened ${file.name}`); - }} - > - - - - - - )} - - - { - e.stopPropagation(); - setShowHistory(true); - onSetStatus(`Viewing history for ${file.name}`); - }} - > - - - {actualFile && ( @@ -372,20 +357,6 @@ const FileThumbnail = ({
- {/* History Modal */} - setShowHistory(false)} - title={`Operation History - ${file.name}`} - size="lg" - scrollAreaComponent={'div' as any} - > - -
); }; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 60467d45d..7ca640b06 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,8 +5,8 @@ import { Stack, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; -import { ViewType, ToolType } from "../../types/fileContext"; +import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; +import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; @@ -18,9 +18,14 @@ import { ToggleSplitCommand } from "../../commands/pageCommands"; import { pdfExportService } from "../../services/pdfExportService"; +import { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService"; +import { fileProcessingService } from "../../services/fileProcessingService"; +import { pdfProcessingService } from "../../services/pdfProcessingService"; +import { pdfWorkerManager } from "../../services/pdfWorkerManager"; import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; import { fileStorage } from "../../services/fileStorage"; +import { indexedDBManager, DATABASE_CONFIGS } from "../../services/indexedDBManager"; import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; import BulkSelectionPanel from './BulkSelectionPanel'; @@ -53,88 +58,170 @@ const PageEditor = ({ }: PageEditorProps) => { const { t } = useTranslation(); - // Get file context - const fileContext = useFileContext(); - const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile(); - - // Use file context state - const { - activeFiles, - processedFiles, - selectedPageNumbers, - setSelectedPages, - updateProcessedFile, - setHasUnsavedChanges, - hasUnsavedChanges, - isProcessing: globalProcessing, - processingProgress, - clearAllFiles - } = fileContext; + // Use split contexts to prevent re-renders + const { state, selectors } = useFileState(); + const { actions } = useFileActions(); + + // Prefer IDs + selectors to avoid array identity churn + const activeFileIds = state.files.ids; + const primaryFileId = activeFileIds[0] ?? null; + const selectedFiles = selectors.getSelectedFiles(); + + // Stable signature for effects (prevents loops) + const filesSignature = selectors.getFilesSignature(); + + // UI state + const globalProcessing = state.ui.isProcessing; + const processingProgress = state.ui.processingProgress; + const hasUnsavedChanges = state.ui.hasUnsavedChanges; + const selectedPageNumbers = state.ui.selectedPageNumbers; // Edit state management const [editedDocument, setEditedDocument] = useState(null); const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false); const [foundDraft, setFoundDraft] = useState(null); - const autoSaveTimer = useRef(null); + const autoSaveTimer = useRef(null); - // Simple computed document from processed files (no caching needed) - const mergedPdfDocument = useMemo(() => { - if (activeFiles.length === 0) return null; + /** + * Create stable files signature to prevent infinite re-computation. + * This signature only changes when files are actually added/removed or processing state changes. + * Using this instead of direct file arrays prevents unnecessary re-renders. + */ + + // Thumbnail generation (opt-in for visual tools) - MUST be before mergedPdfDocument + const { + generateThumbnails, + addThumbnailToCache, + getThumbnailFromCache, + stopGeneration, + destroyThumbnails + } = useThumbnailGeneration(); + - if (activeFiles.length === 1) { - // Single file - const processedFile = processedFiles.get(activeFiles[0]); - if (!processedFile) return null; + // Get primary file record outside useMemo to track processedFile changes + const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; + const processedFilePages = primaryFileRecord?.processedFile?.pages; + const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; - return { - id: processedFile.id, - name: activeFiles[0].name, - file: activeFiles[0], - pages: processedFile.pages.map(page => ({ - ...page, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - })), - totalPages: processedFile.totalPages - }; - } else { - // Multiple files - merge them - const allPages: PDFPage[] = []; - let totalPages = 0; - const filenames: string[] = []; + // Compute merged document with stable signature (prevents infinite loops) + const mergedPdfDocument = useMemo((): PDFDocument | null => { + if (activeFileIds.length === 0) return null; - activeFiles.forEach((file, i) => { - const processedFile = processedFiles.get(file); - if (processedFile) { - filenames.push(file.name.replace(/\.pdf$/i, '')); - - processedFile.pages.forEach((page, pageIndex) => { - const newPage: PDFPage = { - ...page, - id: `${i}-${page.id}`, // Unique ID across all files - pageNumber: totalPages + pageIndex + 1, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - }; - allPages.push(newPage); - }); - - totalPages += processedFile.pages.length; - } - }); - - if (allPages.length === 0) return null; - - return { - id: `merged-${Date.now()}`, - name: filenames.join(' + '), - file: activeFiles[0], // Use first file as reference - pages: allPages, - totalPages: totalPages - }; + 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 (!primaryFileRecord) { + console.log('🎬 PageEditor: No primary file record found, showing loading'); + return null; } - }, [activeFiles, processedFiles]); + + const name = + activeFileIds.length === 1 + ? (primaryFileRecord.name ?? 'document.pdf') + : activeFileIds + .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) + .join(' + '); + + // Get pages from processed file data + const processedFile = primaryFileRecord.processedFile; + + // Debug logging for processed file data + console.log(`🎬 PageEditor: Building document for ${name}`); + console.log(`🎬 ProcessedFile exists:`, !!processedFile); + console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); + console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown'); + if (processedFile?.pages) { + console.log(`🎬 Pages structure:`, processedFile.pages.map(p => ({ pageNumber: p.pageNumber || 'unknown', hasThumbnail: !!p.thumbnail }))); + } + console.log(`🎬 Will use ${(processedFile?.pages?.length || 0) > 0 ? 'PROCESSED' : 'FALLBACK'} pages`); + + // Convert processed pages to PageEditor format or create placeholders from metadata + let pages: PDFPage[] = []; + + 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 { + id: pageId, + pageNumber: index + 1, + thumbnail, + rotation: page.rotation || 0, + selected: false, + splitBefore: page.splitBefore || false, + }; + }); + } else if (processedFile?.totalPages && processedFile.totalPages > 0) { + // Create placeholder pages from metadata while thumbnails are being generated + console.log(`🎬 PageEditor: Creating ${processedFile.totalPages} placeholder pages from metadata`); + 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, + selected: 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 + + return { + id: activeFileIds.length === 1 ? (primaryFileId ?? 'unknown') : `merged:${filesSignature}`, + name, + file: primaryFile || new File([], primaryFileRecord.name), // Create minimal File if needed + pages, + totalPages: pages.length, + destroy: () => {} // Optional cleanup function + }; + }, [filesSignature, primaryFileId, primaryFileRecord]); + // Display document: Use edited version if exists, otherwise original const displayDocument = editedDocument || mergedPdfDocument; @@ -142,16 +229,13 @@ const PageEditor = ({ const [filename, setFilename] = useState(""); + // Page editor state (use context for selectedPages) const [status, setStatus] = useState(null); + const [error, setError] = useState(null); const [csvInput, setCsvInput] = useState(""); const [selectionMode, setSelectionMode] = useState(false); - // Drag and drop state - const [draggedPage, setDraggedPage] = useState(null); - const [dropTarget, setDropTarget] = useState(null); - const [multiPageDrag, setMultiPageDrag] = useState<{pageNumbers: number[], count: number} | null>(null); - const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); // Export state const [exportLoading, setExportLoading] = useState(false); @@ -168,17 +252,22 @@ const PageEditor = ({ // Undo/Redo system const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); - // Set initial filename when document changes + // Set initial filename when document changes - use stable signature useEffect(() => { if (mergedPdfDocument) { - if (activeFiles.length === 1) { - setFilename(activeFiles[0].name.replace(/\.pdf$/i, '')); + if (activeFileIds.length === 1 && primaryFileId) { + const record = selectors.getFileRecord(primaryFileId); + if (record) { + setFilename(record.name.replace(/\.pdf$/i, '')); + } } else { - const filenames = activeFiles.map(f => f.name.replace(/\.pdf$/i, '')); + const filenames = activeFileIds + .map(id => selectors.getFileRecord(id)?.name.replace(/\.pdf$/i, '') || 'file') + .filter(Boolean); setFilename(filenames.join('_')); } } - }, [mergedPdfDocument, activeFiles]); + }, [mergedPdfDocument, filesSignature, primaryFileId, selectors]); // Handle file upload from FileUploadSelector (now using context) const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { @@ -188,168 +277,177 @@ const PageEditor = ({ } // Add files to context - await fileContext.addFiles(uploadedFiles); + await actions.addFiles(uploadedFiles); setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [fileContext]); + }, [actions]); // PageEditor no longer handles cleanup - it's centralized in FileContext - // Shared PDF instance for thumbnail generation - const [sharedPdfInstance, setSharedPdfInstance] = useState(null); - const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false); + // Simple cache-first thumbnail generation (no complex detection needed) - // Thumbnail generation (opt-in for visual tools) - const { - generateThumbnails, - addThumbnailToCache, - getThumbnailFromCache, - stopGeneration, - destroyThumbnails - } = useThumbnailGeneration(); - - // Start thumbnail generation process (separate from document loading) - const startThumbnailGeneration = useCallback(() => { - console.log('🎬 PageEditor: startThumbnailGeneration called'); - console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted); - - if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) { - console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions'); + // Lazy thumbnail generation - only generate when needed, with intelligent batching + const generateMissingThumbnails = useCallback(async () => { + if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) { return; } - const file = activeFiles[0]; + const file = selectors.getFile(primaryFileId); + if (!file) return; + const totalPages = mergedPdfDocument.totalPages; - - console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages'); - setThumbnailGenerationStarted(true); - - // Run everything asynchronously to avoid blocking the main thread - setTimeout(async () => { - try { - // Load PDF array buffer for Web Workers - const arrayBuffer = await file.arrayBuffer(); - - // Generate page numbers for pages that don't have thumbnails yet - const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1) - .filter(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); - return !page?.thumbnail; // Only generate for pages without thumbnails - }); - - console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : ''); - - // If no pages need thumbnails, we're done - if (pageNumbers.length === 0) { - console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed'); - return; + if (totalPages <= 1) return; // Only page 1, nothing to generate + + // For very large documents (2000+ pages), be much more conservative + const isVeryLargeDocument = totalPages > 2000; + + if (isVeryLargeDocument) { + console.log(`πŸ“Έ PageEditor: Very large document (${totalPages} pages) - using minimal thumbnail generation`); + // For very large docs, only generate the next visible batch (pages 2-25) to avoid UI blocking + const pageNumbersToGenerate = []; + for (let pageNum = 2; pageNum <= Math.min(25, totalPages); pageNum++) { + const pageId = `${primaryFileId}-page-${pageNum}`; + if (!getThumbnailFromCache(pageId)) { + pageNumbersToGenerate.push(pageNum); } - - // Calculate quality scale based on file size - const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2; - - // Start parallel thumbnail generation WITHOUT blocking the main thread - const generationPromise = generateThumbnails( - arrayBuffer, - pageNumbers, - { - scale, // Dynamic quality based on file size - quality: 0.8, - batchSize: 15, // Smaller batches per worker for smoother UI - parallelBatches: 3 // Use 3 Web Workers in parallel - }, - // Progress callback (throttled for better performance) - (progress) => { - console.log(`🎬 PageEditor: Progress - ${progress.completed}/${progress.total} pages, ${progress.thumbnails.length} new thumbnails`); - // Batch process thumbnails to reduce main thread work - requestAnimationFrame(() => { - progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { - // Check cache first, then send thumbnail - const pageId = `${file.name}-page-${pageNumber}`; - const cached = getThumbnailFromCache(pageId); - - if (!cached) { - // Cache and send to component - addThumbnailToCache(pageId, thumbnail); - - window.dispatchEvent(new CustomEvent('thumbnailReady', { - detail: { pageNumber, thumbnail, pageId } - })); - console.log(`βœ“ PageEditor: Dispatched thumbnail for page ${pageNumber}`); - } - }); - }); - } - ); - - // Handle completion properly - generationPromise - .then((allThumbnails) => { - console.log(`βœ… PageEditor: Thumbnail generation completed! Generated ${allThumbnails.length} thumbnails`); - // Don't reset thumbnailGenerationStarted here - let it stay true to prevent restarts - }) - .catch(error => { - console.error('βœ— PageEditor: Web Worker thumbnail generation failed:', error); - setThumbnailGenerationStarted(false); - }); - - } catch (error) { - console.error('Failed to start Web Worker thumbnail generation:', error); - setThumbnailGenerationStarted(false); } - }, 0); // setTimeout with 0ms to defer to next tick - }, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]); - - // Start thumbnail generation after document loads - useEffect(() => { - console.log('🎬 PageEditor: Thumbnail generation effect triggered'); - console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted); - - if (mergedPdfDocument && !thumbnailGenerationStarted) { - // Check if ALL pages already have thumbnails from processed files - const totalPages = mergedPdfDocument.pages.length; - const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length; - const hasAllThumbnails = pagesWithThumbnails === totalPages; - - console.log('🎬 PageEditor: Thumbnail status:', { - totalPages, - pagesWithThumbnails, - hasAllThumbnails, - missingThumbnails: totalPages - pagesWithThumbnails - }); - - if (hasAllThumbnails) { - console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist'); - return; // Skip generation if ALL thumbnails already exist + + if (pageNumbersToGenerate.length > 0) { + console.log(`πŸ“Έ PageEditor: Generating initial batch for large doc: pages [${pageNumbersToGenerate.join(', ')}]`); + await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); + } + + // Schedule remaining thumbnails with delay to avoid blocking + setTimeout(() => { + generateRemainingThumbnailsLazily(file, primaryFileId, totalPages, 26); + }, 2000); // 2 second delay before starting background generation + + return; + } + + // For smaller documents, check which pages 2+ need thumbnails + const pageNumbersToGenerate = []; + for (let pageNum = 2; pageNum <= totalPages; pageNum++) { + const pageId = `${primaryFileId}-page-${pageNum}`; + if (!getThumbnailFromCache(pageId)) { + pageNumbersToGenerate.push(pageNum); } - - console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation'); - // Small delay to let document render, then start thumbnail generation - console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms'); - const timer = setTimeout(startThumbnailGeneration, 500); - return () => clearTimeout(timer); } - }, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]); - // Cleanup shared PDF instance when component unmounts (but preserve cache) + if (pageNumbersToGenerate.length === 0) { + console.log(`πŸ“Έ PageEditor: All pages 2+ already cached, skipping generation`); + return; + } + + console.log(`πŸ“Έ PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.slice(0, 5).join(', ')}${pageNumbersToGenerate.length > 5 ? '...' : ''}]`); + await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); + }, [mergedPdfDocument, primaryFileId, activeFileIds, selectors]); + + // Helper function to generate thumbnails in batches + const generateThumbnailBatch = useCallback(async (file: File, fileId: string, pageNumbers: number[]) => { + try { + // Load PDF array buffer for Web Workers + const arrayBuffer = await file.arrayBuffer(); + + // Calculate quality scale based on file size + const scale = calculateScaleFromFileSize(selectors.getFileRecord(fileId)?.size || 0); + + // Start parallel thumbnail generation WITHOUT blocking the main thread + await generateThumbnails( + fileId, // Add fileId as first parameter + arrayBuffer, + pageNumbers, + { + scale, // Dynamic quality based on file size + quality: 0.8, + batchSize: 15, // Smaller batches per worker for smoother UI + parallelBatches: 3 // Use 3 Web Workers in parallel + }, + // Progress callback for thumbnail updates + (progress: { completed: number; total: number; thumbnails: Array<{ pageNumber: number; thumbnail: string }> }) => { + // Batch process thumbnails to reduce main thread work + requestAnimationFrame(() => { + progress.thumbnails.forEach(({ pageNumber, thumbnail }: { pageNumber: number; thumbnail: string }) => { + // Use stable fileId for cache key + const pageId = `${fileId}-page-${pageNumber}`; + addThumbnailToCache(pageId, thumbnail); + + // Don't update context state - thumbnails stay in cache only + // This eliminates per-page context rerenders + // PageThumbnail will find thumbnails via cache polling + }); + }); + } + ); + + // Removed verbose logging - only log errors + } catch (error) { + console.error('PageEditor: Thumbnail generation failed:', error); + } + }, [generateThumbnails, addThumbnailToCache, selectors]); + + // Background generation for remaining pages in very large documents + const generateRemainingThumbnailsLazily = useCallback(async (file: File, fileId: string, totalPages: number, startPage: number) => { + console.log(`πŸ“Έ PageEditor: Starting background thumbnail generation from page ${startPage} to ${totalPages}`); + + // Generate in small chunks to avoid blocking + const CHUNK_SIZE = 50; + for (let start = startPage; start <= totalPages; start += CHUNK_SIZE) { + const end = Math.min(start + CHUNK_SIZE - 1, totalPages); + const chunkPageNumbers = []; + + for (let pageNum = start; pageNum <= end; pageNum++) { + const pageId = `${fileId}-page-${pageNum}`; + if (!getThumbnailFromCache(pageId)) { + chunkPageNumbers.push(pageNum); + } + } + + if (chunkPageNumbers.length > 0) { + // Background thumbnail generation in progress (removed verbose logging) + await generateThumbnailBatch(file, fileId, chunkPageNumbers); + + // Small delay between chunks to keep UI responsive + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + console.log(`πŸ“Έ PageEditor: Background thumbnail generation completed for ${totalPages} pages`); + }, [getThumbnailFromCache, generateThumbnailBatch]); + + // Simple useEffect - just generate missing thumbnails when document is ready + useEffect(() => { + if (mergedPdfDocument && mergedPdfDocument.totalPages > 1) { + console.log(`πŸ“Έ PageEditor: Document ready with ${mergedPdfDocument.totalPages} pages, checking for missing thumbnails`); + generateMissingThumbnails(); + } + }, [mergedPdfDocument, generateMissingThumbnails]); + + // Cleanup thumbnail generation when component unmounts useEffect(() => { return () => { - if (sharedPdfInstance) { - sharedPdfInstance.destroy(); - setSharedPdfInstance(null); + // Stop all PDF.js background processing on unmount + if (stopGeneration) { + stopGeneration(); } - setThumbnailGenerationStarted(false); - // DON'T stop generation on file changes - preserve cache for view switching - // stopGeneration(); + if (destroyThumbnails) { + destroyThumbnails(); + } + // Stop all processing services and destroy workers + enhancedPDFProcessingService.emergencyCleanup(); + fileProcessingService.emergencyCleanup(); + pdfProcessingService.clearAll(); + // Final emergency cleanup of all workers + pdfWorkerManager.emergencyCleanup(); }; - }, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles + }, [stopGeneration, destroyThumbnails]); - // Clear selections when files change + // Clear selections when files change - use stable signature useEffect(() => { - setSelectedPages([]); + actions.setSelectedPages([]); setCsvInput(""); setSelectionMode(false); - }, [activeFiles, setSelectedPages]); + }, [filesSignature, actions]); // Sync csvInput with selectedPageNumbers changes useEffect(() => { @@ -359,64 +457,42 @@ const PageEditor = ({ setCsvInput(newCsvInput); }, [selectedPageNumbers]); - useEffect(() => { - const handleGlobalDragEnd = () => { - // Clean up drag state when drag operation ends anywhere - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - }; - - const handleGlobalDrop = (e: DragEvent) => { - // Prevent default to handle invalid drops - e.preventDefault(); - }; - - if (draggedPage) { - document.addEventListener('dragend', handleGlobalDragEnd); - document.addEventListener('drop', handleGlobalDrop); - } - - return () => { - document.removeEventListener('dragend', handleGlobalDragEnd); - document.removeEventListener('drop', handleGlobalDrop); - }; - }, [draggedPage]); const selectAll = useCallback(() => { if (mergedPdfDocument) { - setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); + actions.setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); } - }, [mergedPdfDocument, setSelectedPages]); + }, [mergedPdfDocument, actions]); - const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]); + const deselectAll = useCallback(() => actions.setSelectedPages([]), [actions]); const togglePage = useCallback((pageNumber: number) => { console.log('πŸ”„ Toggling page', pageNumber); + // Check if currently selected and update accordingly const isCurrentlySelected = selectedPageNumbers.includes(pageNumber); + if (isCurrentlySelected) { // Remove from selection console.log('πŸ”„ Removing page', pageNumber); const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber); - setSelectedPages(newSelectedPageNumbers); + actions.setSelectedPages(newSelectedPageNumbers); } else { // Add to selection console.log('πŸ”„ Adding page', pageNumber); const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; - setSelectedPages(newSelectedPageNumbers); + actions.setSelectedPages(newSelectedPageNumbers); } - }, [selectedPageNumbers, setSelectedPages]); + }, [selectedPageNumbers, actions]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { // Clear selections when exiting selection mode - setSelectedPages([]); + actions.setSelectedPages([]); setCsvInput(""); } return newMode; @@ -432,14 +508,14 @@ const PageEditor = ({ ranges.forEach(range => { if (range.includes('-')) { const [start, end] = range.split('-').map(n => parseInt(n.trim())); - for (let i = start; i <= end && i <= mergedPdfDocument.totalPages; i++) { + for (let i = start; i <= end && i <= mergedPdfDocument.pages.length; i++) { if (i > 0) { pageNumbers.push(i); } } } else { const pageNum = parseInt(range); - if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) { + if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) { pageNumbers.push(pageNum); } } @@ -450,144 +526,115 @@ const PageEditor = ({ const updatePagesFromCSV = useCallback(() => { const pageNumbers = parseCSVInput(csvInput); - setSelectedPages(pageNumbers); - }, [csvInput, parseCSVInput, setSelectedPages]); + actions.setSelectedPages(pageNumbers); + }, [csvInput, parseCSVInput, actions]); - const handleDragStart = useCallback((pageNumber: number) => { - setDraggedPage(pageNumber); - // Check if this is a multi-page drag in selection mode - if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) { - setMultiPageDrag({ - pageNumbers: selectedPageNumbers, - count: selectedPageNumbers.length - }); - } else { - setMultiPageDrag(null); - } - }, [selectionMode, selectedPageNumbers]); - const handleDragEnd = useCallback(() => { - // Clean up drag state regardless of where the drop happened - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - - if (!draggedPage) return; - - // Update drag position for multi-page indicator - if (multiPageDrag) { - setDragPosition({ x: e.clientX, y: e.clientY }); - } - - // Get the element under the mouse cursor - const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); - if (!elementUnderCursor) return; - - // Find the closest page container - const pageContainer = elementUnderCursor.closest('[data-page-number]'); - if (pageContainer) { - const pageNumberStr = pageContainer.getAttribute('data-page-number'); - const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null; - if (pageNumber && pageNumber !== draggedPage) { - setDropTarget(pageNumber); - return; - } - } - - // Check if over the end zone - const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); - if (endZone) { - setDropTarget('end'); - return; - } - - // If not over any valid drop target, clear it - setDropTarget(null); - }, [draggedPage, multiPageDrag]); - - const handleDragEnter = useCallback((pageNumber: number) => { - if (draggedPage && pageNumber !== draggedPage) { - setDropTarget(pageNumber); - } - }, [draggedPage]); - - const handleDragLeave = useCallback(() => { - // Don't clear drop target on drag leave - let dragover handle it - }, []); // Update PDF document state with edit tracking const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { console.log('setPdfDocument called - setting edited state'); + // Update local edit state for immediate visual feedback setEditedDocument(updatedDoc); - setHasUnsavedChanges(true); // Use global state + actions.setHasUnsavedChanges(true); // Use actions from context setHasUnsavedDraft(true); // Mark that we have unsaved draft changes + // Auto-save to drafts (debounced) - only if we have new changes + + // Enhanced auto-save to drafts with proper error handling if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); } - autoSaveTimer.current = setTimeout(() => { + autoSaveTimer.current = window.setTimeout(async () => { if (hasUnsavedDraft) { - saveDraftToIndexedDB(updatedDoc); - setHasUnsavedDraft(false); // Mark draft as saved + try { + await saveDraftToIndexedDB(updatedDoc); + setHasUnsavedDraft(false); // Mark draft as saved + console.log('Auto-save completed successfully'); + } catch (error) { + console.warn('Auto-save failed, will retry on next change:', error); + // Don't set hasUnsavedDraft to false so it will retry + } } }, 30000); // Auto-save after 30 seconds of inactivity + return updatedDoc; - }, [setHasUnsavedChanges, hasUnsavedDraft]); + }, [actions, hasUnsavedDraft]); - // Save draft to separate IndexedDB location + // Enhanced draft save using centralized IndexedDB manager const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { + const draftKey = `draft-${doc.id || 'merged'}`; + try { - const draftKey = `draft-${doc.id || 'merged'}`; + // Export the current document state as PDF bytes + const exportedFile = await pdfExportService.exportPDF(doc, []); + const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer(); + const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean); + + // Generate thumbnail for the draft + let thumbnail: string | undefined; + try { + const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils'); + const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0]; + const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0]; + const file = new File([blob], filename, { type: 'application/pdf' }); + thumbnail = await generateThumbnailForFile(file); + } catch (error) { + console.warn('Failed to generate thumbnail for draft:', error); + } + const draftData = { - document: doc, + id: draftKey, + name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`, + pdfData: pdfBytes, + size: pdfBytes.byteLength, timestamp: Date.now(), - originalFiles: activeFiles.map(f => f.name) + thumbnail, + originalFiles: originalFileNames }; - // Save to 'pdf-drafts' store in IndexedDB - const request = indexedDB.open('stirling-pdf-drafts', 1); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains('drafts')) { - db.createObjectStore('drafts'); - } - }; - - request.onsuccess = () => { - const db = request.result; - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - store.put(draftData, draftKey); + // Use centralized IndexedDB manager + const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); + const transaction = db.transaction('drafts', 'readwrite'); + const store = transaction.objectStore('drafts'); + + const putRequest = store.put(draftData, draftKey); + putRequest.onsuccess = () => { console.log('Draft auto-saved to IndexedDB'); }; + putRequest.onerror = () => { + console.warn('Failed to put draft data:', putRequest.error); + }; + } catch (error) { console.warn('Failed to auto-save draft:', error); } - }, [activeFiles]); + }, [activeFileIds, selectors]); - // Clean up draft from IndexedDB + // Enhanced draft cleanup using centralized IndexedDB manager const cleanupDraft = useCallback(async () => { + const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; + try { - const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; - const request = indexedDB.open('stirling-pdf-drafts', 1); - - request.onsuccess = () => { - const db = request.result; - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - store.delete(draftKey); + // Use centralized IndexedDB manager + const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); + const transaction = db.transaction('drafts', 'readwrite'); + const store = transaction.objectStore('drafts'); + + const deleteRequest = store.delete(draftKey); + deleteRequest.onsuccess = () => { + console.log('Draft cleaned up successfully'); }; + deleteRequest.onerror = () => { + console.warn('Failed to delete draft:', deleteRequest.error); + }; + } catch (error) { console.warn('Failed to cleanup draft:', error); } @@ -597,45 +644,30 @@ const PageEditor = ({ const applyChanges = useCallback(async () => { if (!editedDocument || !mergedPdfDocument) return; + try { - if (activeFiles.length === 1) { - const file = activeFiles[0]; - const currentProcessedFile = processedFiles.get(file); - - if (currentProcessedFile) { - const updatedProcessedFile = { - ...currentProcessedFile, - id: `${currentProcessedFile.id}-edited-${Date.now()}`, - pages: editedDocument.pages.map(page => ({ - ...page, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - })), - totalPages: editedDocument.pages.length, - lastModified: Date.now() - }; - - updateProcessedFile(file, updatedProcessedFile); - } - } else if (activeFiles.length > 1) { + if (activeFileIds.length === 1 && primaryFileId) { + const file = selectors.getFile(primaryFileId); + if (!file) return; + + // Apply changes simplified - no complex dispatch loops + setStatus('Changes applied successfully'); + } else if (activeFileIds.length > 1) { setStatus('Apply changes for multiple files not yet supported'); return; } - // Wait for the processed file update to complete before clearing edit state - setTimeout(() => { - setEditedDocument(null); - setHasUnsavedChanges(false); - setHasUnsavedDraft(false); - cleanupDraft(); - setStatus('Changes applied successfully'); - }, 100); + // Clear edit state immediately + setEditedDocument(null); + actions.setHasUnsavedChanges(false); + setHasUnsavedDraft(false); + cleanupDraft(); } catch (error) { console.error('Failed to apply changes:', error); setStatus('Failed to apply changes'); } - }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile, setHasUnsavedChanges, setStatus, cleanupDraft]); + }, [editedDocument, mergedPdfDocument, activeFileIds, primaryFileId, selectors, actions, cleanupDraft]); const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { if (!displayDocument || isAnimating) return; @@ -654,6 +686,7 @@ const PageEditor = ({ // Skip animation for large documents (500+ pages) to improve performance const isLargeDocument = displayDocument.pages.length > 500; + if (isLargeDocument) { // For large documents, just execute the command without animation if (pagesToMove.length > 1) { @@ -679,6 +712,7 @@ const PageEditor = ({ // Only capture positions for potentially affected pages const currentPositions = new Map(); + affectedPageIds.forEach(pageId => { const element = document.querySelector(`[data-page-number="${pageId}"]`); if (element) { @@ -729,13 +763,16 @@ const PageEditor = ({ if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { elementsToAnimate.push(element); + // Apply initial transform element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; element.style.transition = 'none'; + // Force reflow element.offsetHeight; + // Animate to final position element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; element.style.transform = 'translate(0px, 0px)'; @@ -756,34 +793,22 @@ const PageEditor = ({ }, 10); // Small delay to allow state update }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); - const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => { - e.preventDefault(); - if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return; + const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => { + if (!displayDocument) return; - let targetIndex: number; - if (targetPageNumber === 'end') { - targetIndex = displayDocument.pages.length; - } else { - targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); - if (targetIndex === -1) return; - } + const pagesToMove = selectedPages && selectedPages.length > 1 + ? selectedPages + : [sourcePageNumber]; + + const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber); + if (sourceIndex === -1 || sourceIndex === targetIndex) return; - animateReorder(draggedPage, targetIndex); - - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - - const moveCount = multiPageDrag ? multiPageDrag.count : 1; + animateReorder(sourcePageNumber, targetIndex); + + const moveCount = pagesToMove.length; setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); - }, [draggedPage, displayDocument, animateReorder, multiPageDrag]); + }, [displayDocument, animateReorder]); - const handleEndZoneDragEnter = useCallback(() => { - if (draggedPage) { - setDropTarget('end'); - } - }, [draggedPage]); const handleRotate = useCallback((direction: 'left' | 'right') => { if (!displayDocument) return; @@ -830,11 +855,11 @@ const PageEditor = ({ executeCommand(command); if (selectionMode) { - setSelectedPages([]); + actions.setSelectedPages([]); } const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]); + }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]); const handleSplit = useCallback(() => { if (!displayDocument) return; @@ -870,6 +895,7 @@ const PageEditor = ({ }).filter(id => id) : []; + const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); setShowExportModal(true); @@ -888,6 +914,7 @@ const PageEditor = ({ }).filter(id => id) : []; + const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); if (errors.length > 0) { setStatus(errors.join(', ')); @@ -922,6 +949,7 @@ const PageEditor = ({ } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Export failed'; setStatus(errorMessage); + setStatus(errorMessage); } finally { setExportLoading(false); } @@ -940,58 +968,80 @@ const PageEditor = ({ }, [redo]); const closePdf = useCallback(() => { - // Use global navigation guard system - fileContext.requestNavigation(() => { - clearAllFiles(); // This now handles all cleanup centrally (including merged docs) - setSelectedPages([]); - }); - }, [fileContext, clearAllFiles, setSelectedPages]); + // Stop all PDF.js background processing immediately + if (stopGeneration) { + stopGeneration(); + } + if (destroyThumbnails) { + destroyThumbnails(); + } + // Stop enhanced PDF processing and destroy workers + enhancedPDFProcessingService.emergencyCleanup(); + // Stop file processing service and destroy workers + fileProcessingService.emergencyCleanup(); + // Stop PDF processing service + pdfProcessingService.clearAll(); + // Emergency cleanup - destroy all PDF workers + pdfWorkerManager.emergencyCleanup(); + + // Clear files from memory only (preserves files in storage/recent files) + const allFileIds = selectors.getAllFileIds(); + actions.removeFiles(allFileIds, false); // false = don't delete from storage + actions.setSelectedPages([]); + }, [actions, selectors, stopGeneration, destroyThumbnails]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); - // Expose functions to parent component for PageEditorControls + /** + * Stable function proxy pattern to prevent infinite loops. + * + * Problem: If we include selectedPages in useEffect dependencies, every page selection + * change triggers onFunctionsReady β†’ parent re-renders β†’ PageEditor unmounts/remounts β†’ infinite loop + * + * Solution: Create a stable proxy object that uses getters to access current values + * without triggering parent re-renders when values change. + */ + const pageEditorFunctionsRef = useRef({ + handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, + showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, + selectedPages: selectedPageNumbers, closePdf, + }); + + // Update ref with current values (no parent notification) + pageEditorFunctionsRef.current = { + handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, + showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, + selectedPages: selectedPageNumbers, closePdf, + }; + + // Only call onFunctionsReady once - use stable proxy for live updates useEffect(() => { if (onFunctionsReady) { - onFunctionsReady({ - handleUndo, - handleRedo, - canUndo, - canRedo, - handleRotate, - handleDelete, - handleSplit, - showExportPreview, - onExportSelected, - onExportAll, - exportLoading, - selectionMode, - selectedPages: selectedPageNumbers, - closePdf, - }); + const stableFunctions = { + get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; }, + get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; }, + get canUndo() { return pageEditorFunctionsRef.current.canUndo; }, + get canRedo() { return pageEditorFunctionsRef.current.canRedo; }, + get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; }, + get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; }, + get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; }, + get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; }, + get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; }, + get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; }, + get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; }, + get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; }, + get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; }, + get closePdf() { return pageEditorFunctionsRef.current.closePdf; }, + }; + onFunctionsReady(stableFunctions); } - }, [ - onFunctionsReady, - handleUndo, - handleRedo, - canUndo, - canRedo, - handleRotate, - handleDelete, - handleSplit, - showExportPreview, - onExportSelected, - onExportAll, - exportLoading, - selectionMode, - selectedPageNumbers, - closePdf - ]); + }, [onFunctionsReady]); // Show loading or empty state instead of blocking - const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); - const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0; + const showLoading = !mergedPdfDocument && (globalProcessing || activeFileIds.length > 0); + const showEmpty = !mergedPdfDocument && !globalProcessing && activeFileIds.length === 0; // Functions for global NavigationWarningModal const handleApplyAndContinue = useCallback(async () => { if (editedDocument) { @@ -1006,38 +1056,47 @@ const PageEditor = ({ } }, [editedDocument, applyChanges, handleExport]); - // Check for existing drafts + // Enhanced draft checking using centralized IndexedDB manager const checkForDrafts = useCallback(async () => { if (!mergedPdfDocument) return; + try { const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; - const request = indexedDB.open('stirling-pdf-drafts', 1); + // Use centralized IndexedDB manager + const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); + + // Check if the drafts object store exists before using it + if (!db.objectStoreNames.contains('drafts')) { + console.log('πŸ“ Drafts object store not found, skipping draft check'); + return; + } + + const transaction = db.transaction('drafts', 'readonly'); + const store = transaction.objectStore('drafts'); + const getRequest = store.get(draftKey); - request.onsuccess = () => { - const db = request.result; - if (!db.objectStoreNames.contains('drafts')) return; + getRequest.onsuccess = () => { + const draft = getRequest.result; + if (draft && draft.timestamp) { + // Check if draft is recent (within last 24 hours) + const draftAge = Date.now() - draft.timestamp; + const twentyFourHours = 24 * 60 * 60 * 1000; - const transaction = db.transaction('drafts', 'readonly'); - const store = transaction.objectStore('drafts'); - const getRequest = store.get(draftKey); - - getRequest.onsuccess = () => { - const draft = getRequest.result; - if (draft && draft.timestamp) { - // Check if draft is recent (within last 24 hours) - const draftAge = Date.now() - draft.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (draftAge < twentyFourHours) { - setFoundDraft(draft); - setShowResumeModal(true); - } + if (draftAge < twentyFourHours) { + setFoundDraft(draft); + setShowResumeModal(true); } - }; + } }; + + getRequest.onerror = () => { + console.warn('Failed to get draft:', getRequest.error); + }; + } catch (error) { - console.warn('Failed to check for drafts:', error); + console.warn('Draft check failed:', error); + // Don't throw - draft checking failure shouldn't break the app } }, [mergedPdfDocument]); @@ -1045,12 +1104,12 @@ const PageEditor = ({ const resumeWork = useCallback(() => { if (foundDraft && foundDraft.document) { setEditedDocument(foundDraft.document); - setHasUnsavedChanges(true); + actions.setHasUnsavedChanges(true); // Use context action setFoundDraft(null); setShowResumeModal(false); setStatus('Resumed previous work'); } - }, [foundDraft]); + }, [foundDraft, actions]); // Start fresh (ignore draft) const startFresh = useCallback(() => { @@ -1065,17 +1124,15 @@ const PageEditor = ({ // Cleanup on unmount useEffect(() => { return () => { - console.log('PageEditor unmounting - cleaning up resources'); // Clear auto-save timer if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); } - // Clean up draft if component unmounts with unsaved changes - if (hasUnsavedChanges) { - cleanupDraft(); - } + + // Note: We intentionally do NOT clean up drafts on unmount + // Drafts should persist when navigating away so users can resume later }; }, [hasUnsavedChanges, cleanupDraft]); @@ -1109,7 +1166,7 @@ const PageEditor = ({ const displayedPages = displayDocument?.pages || []; return ( - + {showEmpty && ( @@ -1123,9 +1180,10 @@ const PageEditor = ({ )} {showLoading && ( - + + {/* Progress indicator */} @@ -1152,12 +1210,13 @@ const PageEditor = ({
+ )} {displayDocument && ( - + {/* Enhanced Processing Status */} {globalProcessing && processingProgress < 100 && ( @@ -1211,6 +1270,7 @@ const PageEditor = ({ )} + {/* Apply Changes Button */} {hasUnsavedChanges && ( - {errorMessage && ( - - {errorMessage} - - )} - {downloadUrl && ( - - )} - updateParams({ removeDuplicates: !removeDuplicates })} - /> - - ); -}; - -export default MergePdfPanel; diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index 72fac0b37..7f918759e 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -16,8 +16,8 @@ import { useOCRTips } from "../components/tooltips/useOCRTips"; const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); @@ -66,13 +66,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "ocr"); - setCurrentMode("viewer"); }; const handleSettingsReset = () => { ocrOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("ocr"); }; const settingsCollapsed = expandedStep !== "settings"; diff --git a/frontend/src/tools/RemoveCertificateSign.tsx b/frontend/src/tools/RemoveCertificateSign.tsx index e33675625..0e08e117c 100644 --- a/frontend/src/tools/RemoveCertificateSign.tsx +++ b/frontend/src/tools/RemoveCertificateSign.tsx @@ -2,7 +2,8 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useFileSelection } from "../contexts/file/fileHooks"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool"; const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const removeCertificateSignParams = useRemoveCertificateSignParameters(); const removeCertificateSignOperation = useRemoveCertificateSignOperation(); @@ -42,13 +43,12 @@ const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolP const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "removeCertificateSign"); - setCurrentMode("viewer"); + actions.setMode("viewer"); }; const handleSettingsReset = () => { removeCertificateSignOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("removeCertificateSign"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/RemovePassword.tsx b/frontend/src/tools/RemovePassword.tsx index 31744186b..4b4d1f8d6 100644 --- a/frontend/src/tools/RemovePassword.tsx +++ b/frontend/src/tools/RemovePassword.tsx @@ -1,8 +1,8 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -15,8 +15,8 @@ import { BaseToolProps } from "../types/tool"; const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const removePasswordParams = useRemovePasswordParameters(); const removePasswordOperation = useRemovePasswordOperation(); @@ -25,6 +25,7 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = // Endpoint validation const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removePasswordParams.getEndpointName()); + useEffect(() => { removePasswordOperation.resetResults(); onPreviewFile?.(null); @@ -46,13 +47,11 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "removePassword"); - setCurrentMode("viewer"); }; const handleSettingsReset = () => { removePasswordOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("removePassword"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/Repair.tsx b/frontend/src/tools/Repair.tsx index fc30b9b95..8cb061085 100644 --- a/frontend/src/tools/Repair.tsx +++ b/frontend/src/tools/Repair.tsx @@ -2,7 +2,8 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useFileSelection } from "../contexts/file/fileHooks"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool"; const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const repairParams = useRepairParameters(); const repairOperation = useRepairOperation(); @@ -42,13 +43,12 @@ const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "repair"); - setCurrentMode("viewer"); + actions.setMode("viewer"); }; const handleSettingsReset = () => { repairOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("repair"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 258f0f930..1d12d1bee 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -1,7 +1,8 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings"; @@ -9,13 +10,12 @@ import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings"; import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters"; import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation"; import { BaseToolProps } from "../types/tool"; -import { useFileContext } from "../contexts/FileContext"; const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useToolFileSelection(); - const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useFileSelection(); + const { actions } = useNavigationActions(); const sanitizeParams = useSanitizeParameters(); const sanitizeOperation = useSanitizeOperation(); @@ -44,13 +44,11 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleSettingsReset = () => { sanitizeOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("sanitize"); }; const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "sanitize"); - setCurrentMode("viewer"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/SingleLargePage.tsx b/frontend/src/tools/SingleLargePage.tsx index 0c4fb96db..7d268d11c 100644 --- a/frontend/src/tools/SingleLargePage.tsx +++ b/frontend/src/tools/SingleLargePage.tsx @@ -2,7 +2,8 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useFileSelection } from "../contexts/file/fileHooks"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool"; const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const singleLargePageParams = useSingleLargePageParameters(); const singleLargePageOperation = useSingleLargePageOperation(); @@ -42,13 +43,12 @@ const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "single-large-page"); - setCurrentMode("viewer"); + actions.setMode("viewer"); }; const handleSettingsReset = () => { singleLargePageOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("single-large-page"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index ea68404f0..18997e6a6 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -1,8 +1,8 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import SplitSettings from "../components/tools/split/SplitSettings"; @@ -13,8 +13,8 @@ import { BaseToolProps } from "../types/tool"; const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const splitParams = useSplitParameters(); const splitOperation = useSplitOperation(); @@ -25,8 +25,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { useEffect(() => { splitOperation.resetResults(); onPreviewFile?.(null); - }, [splitParams.parameters]); - + }, [splitParams.parameters, selectedFiles]); const handleSplit = async () => { try { await splitOperation.executeOperation(splitParams.parameters, selectedFiles); @@ -43,13 +42,12 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "split"); - setCurrentMode("viewer"); }; const handleSettingsReset = () => { splitOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("split"); + actions.setMode("split"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/UnlockPdfForms.tsx b/frontend/src/tools/UnlockPdfForms.tsx index b8aee7894..a169c8e58 100644 --- a/frontend/src/tools/UnlockPdfForms.tsx +++ b/frontend/src/tools/UnlockPdfForms.tsx @@ -2,7 +2,8 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useFileSelection } from "../contexts/file/fileHooks"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool"; const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); - const { selectedFiles } = useToolFileSelection(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useFileSelection(); const unlockPdfFormsParams = useUnlockPdfFormsParameters(); const unlockPdfFormsOperation = useUnlockPdfFormsOperation(); @@ -42,13 +43,12 @@ const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "unlockPdfForms"); - setCurrentMode("viewer"); + actions.setMode("viewer"); }; const handleSettingsReset = () => { unlockPdfFormsOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("unlockPdfForms"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index c887c093b..96e507523 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -1,12 +1,21 @@ /** - * Enhanced file types for IndexedDB storage + * File types for the new architecture + * FileContext uses pure File objects with separate ID tracking */ -export interface FileWithUrl extends File { - id?: string; - url?: string; + +/** + * File metadata for efficient operations without loading full file data + * Used by IndexedDBContext and FileContext for lazy file loading + */ +export interface FileMetadata { + id: string; + name: string; + type: string; + size: number; + lastModified: number; thumbnail?: string; - storedInIndexedDB?: boolean; + isDraft?: boolean; // Marks files as draft versions } export interface StorageConfig { diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index d9dde75b7..0425031c5 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -4,6 +4,7 @@ import { ProcessedFile } from './processing'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; +import { FileMetadata } from './file'; export type ModeType = | 'viewer' @@ -17,16 +18,116 @@ export type ModeType = | 'sanitize' | 'addPassword' | 'changePermissions' - | 'watermark' + | 'addWatermark' | 'removePassword' | 'single-large-page' | 'repair' | 'unlockPdfForms' | 'removeCertificateSign'; -export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; +// Normalized state types +export type FileId = string; -export type ToolType = 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize'; +export interface ProcessedFilePage { + thumbnail?: string; + pageNumber?: number; + rotation?: number; + splitBefore?: boolean; + [key: string]: any; +} + +export interface ProcessedFileMetadata { + pages: ProcessedFilePage[]; + totalPages?: number; + thumbnailUrl?: string; + lastProcessed?: number; + [key: string]: any; +} + +export interface FileRecord { + id: FileId; + name: string; + size: number; + type: string; + lastModified: number; + quickKey?: string; // Fast deduplication key: name|size|lastModified + thumbnailUrl?: string; + blobUrl?: string; + createdAt?: number; + processedFile?: ProcessedFileMetadata; + isPinned?: boolean; + // Note: File object stored in provider ref, not in state +} + +export interface FileContextNormalizedFiles { + ids: FileId[]; + byId: Record; +} + +// Helper functions - UUID-based primary keys (zero collisions, synchronous) +export function createFileId(): FileId { + // Use crypto.randomUUID for authoritative primary key + if (typeof window !== 'undefined' && window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + // Fallback for environments without randomUUID + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +// Generate quick deduplication key from file metadata +export function createQuickKey(file: File): string { + // Format: name|size|lastModified for fast duplicate detection + return `${file.name}|${file.size}|${file.lastModified}`; +} + + + +export function toFileRecord(file: File, id?: FileId): FileRecord { + const fileId = id || createFileId(); + return { + id: fileId, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + quickKey: createQuickKey(file), + createdAt: Date.now() + }; +} + +export function revokeFileResources(record: FileRecord): void { + // Only revoke blob: URLs to prevent errors on other schemes + if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(record.thumbnailUrl); + } catch (error) { + console.warn('Failed to revoke thumbnail URL:', error); + } + } + if (record.blobUrl && record.blobUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(record.blobUrl); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } + } + // Clean up processed file thumbnails + if (record.processedFile?.pages) { + record.processedFile.pages.forEach(page => { + if (page.thumbnail && page.thumbnail.startsWith('blob:')) { + try { + URL.revokeObjectURL(page.thumbnail); + } catch (error) { + console.warn('Failed to revoke page thumbnail URL:', error); + } + } + }); + } +} export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr' | 'sanitize'; @@ -69,114 +170,110 @@ export interface FileEditHistory { } export interface FileContextState { - // Core file management - activeFiles: File[]; - processedFiles: Map; - pinnedFiles: Set; // Files that are pinned and won't be consumed - - // Current navigation state - currentMode: ModeType; - currentView: ViewType; - currentTool: ToolType | null; - - // Edit history and state - fileEditHistory: Map; - globalFileOperations: FileOperation[]; - // New comprehensive operation history - fileOperationHistory: Map; - - // UI state that persists across views - selectedFileIds: string[]; - selectedPageNumbers: number[]; - viewerConfig: ViewerConfig; - - // Processing state - isProcessing: boolean; - processingProgress: number; - - // Export state - lastExportConfig?: { - filename: string; - selectedOnly: boolean; - splitDocuments: boolean; + // Core file management - lightweight file IDs only + files: { + ids: FileId[]; + byId: Record; + }; + + // Pinned files - files that won't be consumed by tools + pinnedFiles: Set; + + // UI state - file-related UI state only + ui: { + selectedFileIds: FileId[]; + selectedPageNumbers: number[]; + isProcessing: boolean; + processingProgress: number; + hasUnsavedChanges: boolean; }; - - // Navigation guard system - hasUnsavedChanges: boolean; - pendingNavigation: (() => void) | null; - showNavigationWarning: boolean; } +// Action types for reducer pattern +export type FileContextAction = + // File management actions + | { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } } + | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } + | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } + | { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } } + + // Pinned files actions + | { type: 'PIN_FILE'; payload: { fileId: FileId } } + | { type: 'UNPIN_FILE'; payload: { fileId: FileId } } + | { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } } + + // UI actions + | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } + | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } + | { type: 'CLEAR_SELECTIONS' } + | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } + + // Navigation guard actions (minimal for file-related unsaved changes only) + | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } + + // Context management + | { type: 'RESET_CONTEXT' }; + export interface FileContextActions { - // File management + // File management - lightweight actions only addFiles: (files: File[]) => Promise; - removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; - replaceFile: (oldFileId: string, newFile: File) => Promise; - clearAllFiles: () => void; + addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; + addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise; + removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; + updateFileRecord: (id: FileId, updates: Partial) => void; + reorderFiles: (orderedFileIds: FileId[]) => void; + clearAllFiles: () => Promise; // File pinning pinFile: (file: File) => void; unpinFile: (file: File) => void; - isFilePinned: (file: File) => boolean; // File consumption (replace unpinned files with outputs) - consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise; - - // Navigation - setCurrentMode: (mode: ModeType) => void; - setCurrentView: (view: ViewType) => void; - setCurrentTool: (tool: ToolType) => void; + consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; // Selection management - setSelectedFiles: (fileIds: string[]) => void; + setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; - updateProcessedFile: (file: File, processedFile: ProcessedFile) => void; clearSelections: () => void; - - // Edit operations - applyPageOperations: (fileId: string, operations: PageOperation[]) => void; - applyFileOperation: (operation: FileOperation) => void; - undoLastOperation: (fileId?: string) => void; - - // Operation history management - recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void; - markOperationApplied: (fileId: string, operationId: string) => void; - markOperationFailed: (fileId: string, operationId: string, error: string) => void; - getFileHistory: (fileId: string) => FileOperationHistory | undefined; - getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[]; - clearFileHistory: (fileId: string) => void; - - // Viewer state - updateViewerConfig: (config: Partial) => void; - - // Export configuration - setExportConfig: (config: FileContextState['lastExportConfig']) => void; - - - // Utility - getFileById: (fileId: string) => File | undefined; - getProcessedFileById: (fileId: string) => ProcessedFile | undefined; - getCurrentFile: () => File | undefined; - getCurrentProcessedFile: () => ProcessedFile | undefined; - - // Context persistence - saveContext: () => Promise; - loadContext: () => Promise; - resetContext: () => void; - - // Navigation guard system + + // Processing state - simple flags only + setProcessing: (isProcessing: boolean, progress?: number) => void; + + // File-related unsaved changes (minimal navigation guard support) setHasUnsavedChanges: (hasChanges: boolean) => void; - requestNavigation: (navigationFn: () => void) => boolean; - confirmNavigation: () => void; - cancelNavigation: () => void; - - // Memory management + + // Context management + resetContext: () => void; + + // Resource management trackBlobUrl: (url: string) => void; - trackPdfDocument: (fileId: string, pdfDoc: any) => void; - cleanupFile: (fileId: string) => Promise; scheduleCleanup: (fileId: string, delay?: number) => void; + cleanupFile: (fileId: string) => void; } -export interface FileContextValue extends FileContextState, FileContextActions {} +// File selectors (separate from actions to avoid re-renders) +export interface FileContextSelectors { + // File access - no state dependency, uses ref + getFile: (id: FileId) => File | undefined; + getFiles: (ids?: FileId[]) => File[]; + + // Record access - uses normalized state + getFileRecord: (id: FileId) => FileRecord | undefined; + getFileRecords: (ids?: FileId[]) => FileRecord[]; + + // Derived selectors + getAllFileIds: () => FileId[]; + getSelectedFiles: () => File[]; + getSelectedFileRecords: () => FileRecord[]; + + // Pinned files selectors + getPinnedFileIds: () => FileId[]; + getPinnedFiles: () => File[]; + getPinnedFileRecords: () => FileRecord[]; + isFilePinned: (file: File) => boolean; + + // Stable signature for effect dependencies + getFilesSignature: () => string; +} export interface FileContextProviderProps { children: React.ReactNode; @@ -185,16 +282,16 @@ export interface FileContextProviderProps { maxCacheSize?: number; } -// Helper types for component props -export interface WithFileContext { - fileContext: FileContextValue; +// Split context values to minimize re-renders +export interface FileContextStateValue { + state: FileContextState; + selectors: FileContextSelectors; } -// URL parameter types for deep linking -export interface FileContextUrlParams { - mode?: ModeType; - fileIds?: string[]; - pageIds?: string[]; - zoom?: number; - page?: number; +export interface FileContextActionsValue { + actions: FileContextActions; + dispatch: (action: FileContextAction) => void; } + +// TODO: URL parameter types will be redesigned for new routing system + diff --git a/frontend/src/types/tool.ts b/frontend/src/types/tool.ts index e5e8c24e2..463b6a63e 100644 --- a/frontend/src/types/tool.ts +++ b/frontend/src/types/tool.ts @@ -54,24 +54,3 @@ export interface Tool { export type ToolRegistry = Record; -export interface FileSelectionState { - selectedFiles: File[]; - maxFiles: MaxFiles; - isToolMode: boolean; -} - -export interface FileSelectionActions { - setSelectedFiles: (files: File[]) => void; - setMaxFiles: (maxFiles: MaxFiles) => void; - setIsToolMode: (isToolMode: boolean) => void; - clearSelection: () => void; -} - -export interface FileSelectionComputed { - canSelectMore: boolean; - isAtLimit: boolean; - selectionCount: number; - isMultiFileMode: boolean; -} - -export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {} diff --git a/frontend/src/utils/downloadUtils.ts b/frontend/src/utils/downloadUtils.ts index 1c411c87d..404e10925 100644 --- a/frontend/src/utils/downloadUtils.ts +++ b/frontend/src/utils/downloadUtils.ts @@ -1,4 +1,4 @@ -import { FileWithUrl } from '../types/file'; +import { FileMetadata } from '../types/file'; import { fileStorage } from '../services/fileStorage'; import { zipFileService } from '../services/zipFileService'; @@ -26,8 +26,8 @@ export function downloadBlob(blob: Blob, filename: string): void { * @param file - The file object with storage information * @throws Error if file cannot be retrieved from storage */ -export async function downloadFileFromStorage(file: FileWithUrl): Promise { - const lookupKey = file.id || file.name; +export async function downloadFileFromStorage(file: FileMetadata): Promise { + const lookupKey = file.id; const storedFile = await fileStorage.getFile(lookupKey); if (!storedFile) { @@ -42,7 +42,7 @@ export async function downloadFileFromStorage(file: FileWithUrl): Promise * Downloads multiple files as individual downloads * @param files - Array of files to download */ -export async function downloadMultipleFiles(files: FileWithUrl[]): Promise { +export async function downloadMultipleFiles(files: FileMetadata[]): Promise { for (const file of files) { await downloadFileFromStorage(file); } @@ -53,7 +53,7 @@ export async function downloadMultipleFiles(files: FileWithUrl[]): Promise * @param files - Array of files to include in ZIP * @param zipFilename - Optional custom ZIP filename (defaults to timestamped name) */ -export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: string): Promise { +export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: string): Promise { if (files.length === 0) { throw new Error('No files provided for ZIP download'); } @@ -61,7 +61,7 @@ export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: str // Convert stored files to File objects const fileObjects: File[] = []; for (const fileWithUrl of files) { - const lookupKey = fileWithUrl.id || fileWithUrl.name; + const lookupKey = fileWithUrl.id; const storedFile = await fileStorage.getFile(lookupKey); if (storedFile) { @@ -94,7 +94,7 @@ export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: str * @param options - Download options */ export async function downloadFiles( - files: FileWithUrl[], + files: FileMetadata[], options: { forceZip?: boolean; zipFilename?: string; diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index 040fc14c7..b7e3a429c 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,9 +1,4 @@ -import { FileWithUrl } from "../types/file"; -import { StoredFile, fileStorage } from "../services/fileStorage"; - -export function getFileId(file: File): string | null { - return (file as File & { id?: string }).id || null; -} +// Pure utility functions for file operations /** * Consolidated file size formatting utility @@ -19,7 +14,7 @@ export function formatFileSize(bytes: number): string { /** * Get file date as string */ -export function getFileDate(file: File): string { +export function getFileDate(file: File | { lastModified: number }): string { if (file.lastModified) { return new Date(file.lastModified).toLocaleString(); } @@ -29,107 +24,12 @@ export function getFileDate(file: File): string { /** * Get file size as string (legacy method for backward compatibility) */ -export function getFileSize(file: File): string { +export function getFileSize(file: File | { size: number }): string { if (!file.size) return "Unknown"; return formatFileSize(file.size); } -/** - * Create enhanced file object from stored file metadata - * This eliminates the repeated pattern in FileManager - */ -export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?: string): FileWithUrl { - const enhancedFile: FileWithUrl = { - id: storedFile.id, - storedInIndexedDB: true, - url: undefined, // Don't create blob URL immediately to save memory - thumbnail: thumbnail || storedFile.thumbnail, - // File metadata - name: storedFile.name, - size: storedFile.size, - type: storedFile.type, - lastModified: storedFile.lastModified, - webkitRelativePath: '', - // Lazy-loading File interface methods - arrayBuffer: async () => { - const data = await fileStorage.getFileData(storedFile.id); - if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`); - return data; - }, - bytes: async () => { - return new Uint8Array(); - }, - slice: (start?: number, end?: number, contentType?: string) => { - // Return a promise-based slice that loads from IndexedDB - return new Blob([], { type: contentType || storedFile.type }); - }, - stream: () => { - throw new Error('Stream not implemented for IndexedDB files'); - }, - text: async () => { - const data = await fileStorage.getFileData(storedFile.id); - if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`); - return new TextDecoder().decode(data); - }, - } as FileWithUrl; - return enhancedFile; -} - -/** - * Load files from IndexedDB and convert to enhanced file objects - */ -export async function loadFilesFromIndexedDB(): Promise { - try { - await fileStorage.init(); - const storedFiles = await fileStorage.getAllFileMetadata(); - - if (storedFiles.length === 0) { - return []; - } - - const restoredFiles: FileWithUrl[] = storedFiles - .filter(storedFile => { - // Filter out corrupted entries - return storedFile && - storedFile.name && - typeof storedFile.size === 'number'; - }) - .map(storedFile => { - try { - return createEnhancedFileFromStored(storedFile as any); - } catch (error) { - console.error('Failed to restore file:', storedFile?.name || 'unknown', error); - return null; - } - }) - .filter((file): file is FileWithUrl => file !== null); - - return restoredFiles; - } catch (error) { - console.error('Failed to load files from IndexedDB:', error); - return []; - } -} - -/** - * Clean up blob URLs from file objects - */ -export function cleanupFileUrls(files: FileWithUrl[]): void { - files.forEach(file => { - if (file.url && !file.url.startsWith('indexeddb:')) { - URL.revokeObjectURL(file.url); - } - }); -} - -/** - * Check if file should use blob URL or IndexedDB direct access - */ -export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean { - const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB - return file.size > FILE_SIZE_LIMIT; -} /** * Detects and normalizes file extension from filename @@ -151,29 +51,3 @@ export function detectFileExtension(filename: string): string { return extension; } - -/** - * Gets the filename without extension - * @param filename - The filename to process - * @returns Filename without extension - */ -export function getFilenameWithoutExtension(filename: string): string { - if (!filename || typeof filename !== 'string') return ''; - - const parts = filename.split('.'); - if (parts.length <= 1) return filename; - - // Return all parts except the last one (extension) - return parts.slice(0, -1).join('.'); -} - -/** - * Creates a new filename with a different extension - * @param filename - Original filename - * @param newExtension - New extension (without dot) - * @returns New filename with the specified extension - */ -export function changeFileExtension(filename: string, newExtension: string): string { - const nameWithoutExt = getFilenameWithoutExtension(filename); - return `${nameWithoutExt}.${newExtension}`; -} diff --git a/frontend/src/utils/storageUtils.ts b/frontend/src/utils/storageUtils.ts index def05b96d..14ae78fee 100644 --- a/frontend/src/utils/storageUtils.ts +++ b/frontend/src/utils/storageUtils.ts @@ -1,5 +1,4 @@ import { StorageStats } from "../services/fileStorage"; -import { FileWithUrl } from "../types/file"; /** * Storage operation types for incremental updates @@ -12,7 +11,7 @@ export type StorageOperation = 'add' | 'remove' | 'clear'; export function updateStorageStatsIncremental( currentStats: StorageStats, operation: StorageOperation, - files: FileWithUrl[] = [] + files: File[] = [] ): StorageStats { const filesSizeTotal = files.reduce((total, file) => total + file.size, 0); diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 72e1bc392..e4a48f9fd 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -1,4 +1,9 @@ -import { getDocument } from "pdfjs-dist"; +import { pdfWorkerManager } from '../services/pdfWorkerManager'; + +export interface ThumbnailWithMetadata { + thumbnail: string; // Always returns a thumbnail (placeholder if needed) + pageCount: number; +} interface ColorScheme { bgTop: string; @@ -11,19 +16,18 @@ interface ColorScheme { } /** - * Calculate thumbnail scale based on file size - * Smaller files get higher quality, larger files get lower quality + * Calculate thumbnail scale based on file size (modern 2024 scaling) */ 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 < 10 * MB) return 1.0; // Full quality for small files + if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes + if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files + if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files + return 0.3; // Still usable quality, not tiny } + /** * Generate encrypted PDF thumbnail with lock icon */ @@ -125,16 +129,40 @@ function getFileTypeColorScheme(extension: string): ColorScheme { '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' }, + 'ODT': { 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' }, + 'RTF': { 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' }, + 'ODS': { 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' }, + 'ODP': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Images + 'JPG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'JPEG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'PNG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'GIF': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'BMP': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'TIFF': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'WEBP': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'SVG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Web + 'HTML': { bgTop: '#FD79A820', bgBottom: '#FD79A810', border: '#FD79A840', icon: '#FD79A8', badge: '#FD79A8', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'XML': { bgTop: '#FD79A820', bgBottom: '#FD79A810', border: '#FD79A840', icon: '#FD79A8', badge: '#FD79A8', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Text/Markup + 'MD': { bgTop: '#6C5CE720', bgBottom: '#6C5CE710', border: '#6C5CE740', icon: '#6C5CE7', badge: '#6C5CE7', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Email + 'EML': { bgTop: '#A29BFE20', bgBottom: '#A29BFE10', border: '#A29BFE40', icon: '#A29BFE', badge: '#A29BFE', textPrimary: '#FFFFFF', textSecondary: '#666666' }, // Archives 'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, @@ -275,16 +303,15 @@ function formatFileSize(bytes: number): string { async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale: number): Promise { try { - const pdf = await getDocument({ - data: arrayBuffer, + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { disableAutoFetch: true, disableStream: true - }).promise; + }); const thumbnail = await generateStandardPDFThumbnail(pdf, scale); - // Immediately clean up memory after thumbnail generation - pdf.destroy(); + // Immediately clean up memory after thumbnail generation using worker manager + pdfWorkerManager.destroyDocument(pdf); return thumbnail; } catch (error) { if (error instanceof Error) { @@ -298,52 +325,105 @@ async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale: } /** - * Generate thumbnail for any file type - * Returns base64 data URL or undefined if generation fails + * Generate thumbnail for any file type - always returns a thumbnail (placeholder if needed) */ -export async function generateThumbnailForFile(file: File): Promise { - // 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); +export async function generateThumbnailForFile(file: File): Promise { + // Skip very large files + if (file.size >= 100 * 1024 * 1024) { return generatePlaceholderThumbnail(file); } - // Handle image files - use original file directly + // Handle image files - convert to data URL for persistence if (file.type.startsWith('image/')) { - return URL.createObjectURL(file); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); } // Handle PDF files - if (!file.type.startsWith('application/pdf')) { - console.log('File is not a PDF or image, generating placeholder:', file.name); - return generatePlaceholderThumbnail(file); - } - - // Calculate quality scale based on file size - console.log('Generating thumbnail for', file.name); - const scale = calculateScaleFromFileSize(file.size); - console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`); - - // 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(); - - try { - return await generatePDFThumbnail(arrayBuffer, file, scale); - } catch (error) { - if (error instanceof Error) { - if (error.name === 'InvalidPDFException') { - console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`); - // Return a placeholder or try with full file instead of chunk - const fullArrayBuffer = await file.arrayBuffer(); - return await generatePDFThumbnail(fullArrayBuffer, file, scale); - } else { - console.warn('Unknown error thrown. Failed to generate thumbnail for', file.name, error); - return undefined; + if (file.type.startsWith('application/pdf')) { + const scale = calculateScaleFromFileSize(file.size); + + // 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(); + + try { + return await generatePDFThumbnail(arrayBuffer, file, scale); + } catch (error) { + if (error instanceof Error && error.name === 'InvalidPDFException') { + console.warn(`PDF structure issue for ${file.name} - trying with full file`); + try { + // Try with full file instead of chunk + const fullArrayBuffer = await file.arrayBuffer(); + return await generatePDFThumbnail(fullArrayBuffer, file, scale); + } catch (fullFileError) { + console.warn(`Full file PDF processing also failed for ${file.name} - using placeholder`); + return generatePlaceholderThumbnail(file); + } } - } else { - throw error; // Re-throw non-Error exceptions + console.warn(`PDF processing failed for ${file.name} - using placeholder:`, error); + return generatePlaceholderThumbnail(file); } } + + // All other files get placeholder + return generatePlaceholderThumbnail(file); } + +/** + * Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail + */ +export async function generateThumbnailWithMetadata(file: File): Promise { + // Non-PDF files have no page count + if (!file.type.startsWith('application/pdf')) { + const thumbnail = await generateThumbnailForFile(file); + return { thumbnail, pageCount: 0 }; + } + + // Skip very large files + if (file.size >= 100 * 1024 * 1024) { + const thumbnail = generatePlaceholderThumbnail(file); + return { thumbnail, pageCount: 1 }; + } + + const scale = calculateScaleFromFileSize(file.size); + + try { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer); + + const pageCount = pdf.numPages; + 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) { + pdfWorkerManager.destroyDocument(pdf); + throw new Error('Could not get canvas context'); + } + + await page.render({ canvasContext: context, viewport }).promise; + const thumbnail = canvas.toDataURL(); + + pdfWorkerManager.destroyDocument(pdf); + return { thumbnail, pageCount }; + + } catch (error) { + if (error instanceof Error && error.name === "PasswordException") { + // Handle encrypted PDFs + const thumbnail = generateEncryptedPDFThumbnail(file); + return { thumbnail, pageCount: 1 }; + } + + const thumbnail = generatePlaceholderThumbnail(file); + return { thumbnail, pageCount: 1 }; + } +} \ No newline at end of file diff --git a/frontend/src/utils/urlRouting.ts b/frontend/src/utils/urlRouting.ts new file mode 100644 index 000000000..fbf6c1c3c --- /dev/null +++ b/frontend/src/utils/urlRouting.ts @@ -0,0 +1,180 @@ +/** + * URL routing utilities for tool navigation + * Provides clean URL routing for the V2 tool system + */ + +import { ModeType } from '../contexts/NavigationContext'; + +export interface ToolRoute { + mode: ModeType; + toolKey?: string; +} + +/** + * Parse the current URL to extract tool routing information + */ +export function parseToolRoute(): ToolRoute { + const path = window.location.pathname; + const searchParams = new URLSearchParams(window.location.search); + + // Extract tool from URL path (e.g., /split-pdf -> split) + const toolMatch = path.match(/\/([a-zA-Z-]+)(?:-pdf)?$/); + if (toolMatch) { + const toolKey = toolMatch[1].toLowerCase(); + + // Map URL paths to tool keys and modes (excluding internal UI modes) + const toolMappings: Record = { + 'split': { mode: 'split', toolKey: 'split' }, + 'merge': { mode: 'merge', toolKey: 'merge' }, + 'compress': { mode: 'compress', toolKey: 'compress' }, + 'convert': { mode: 'convert', toolKey: 'convert' }, + 'add-password': { mode: 'addPassword', toolKey: 'addPassword' }, + 'change-permissions': { mode: 'changePermissions', toolKey: 'changePermissions' }, + 'sanitize': { mode: 'sanitize', toolKey: 'sanitize' }, + 'ocr': { mode: 'ocr', toolKey: 'ocr' } + }; + + const mapping = toolMappings[toolKey]; + if (mapping) { + return { + mode: mapping.mode, + toolKey: mapping.toolKey + }; + } + } + + // Check for query parameter fallback (e.g., ?tool=split) + const toolParam = searchParams.get('tool'); + if (toolParam && isValidMode(toolParam)) { + return { + mode: toolParam as ModeType, + toolKey: toolParam + }; + } + + // Default to page editor for home page + return { + mode: 'pageEditor' + }; +} + +/** + * Update the URL to reflect the current tool selection + * Internal UI modes (viewer, fileEditor, pageEditor) don't get URLs + */ +export function updateToolRoute(mode: ModeType, toolKey?: string): void { + const currentPath = window.location.pathname; + const searchParams = new URLSearchParams(window.location.search); + + // Don't create URLs for internal UI modes + if (mode === 'viewer' || mode === 'fileEditor' || mode === 'pageEditor') { + // If we're switching to an internal mode, clear any existing tool URL + if (currentPath !== '/') { + clearToolRoute(); + } + return; + } + + let newPath = '/'; + + // Map modes to URL paths (only for actual tools) + if (toolKey) { + const pathMappings: Record = { + 'split': '/split-pdf', + 'merge': '/merge-pdf', + 'compress': '/compress-pdf', + 'convert': '/convert-pdf', + 'addPassword': '/add-password-pdf', + 'changePermissions': '/change-permissions-pdf', + 'sanitize': '/sanitize-pdf', + 'ocr': '/ocr-pdf' + }; + + newPath = pathMappings[toolKey] || `/${toolKey}`; + } + + // Remove tool query parameter since we're using path-based routing + searchParams.delete('tool'); + + // Construct final URL + const queryString = searchParams.toString(); + const fullUrl = newPath + (queryString ? `?${queryString}` : ''); + + // Update URL without triggering page reload + if (currentPath !== newPath || window.location.search !== (queryString ? `?${queryString}` : '')) { + window.history.replaceState(null, '', fullUrl); + } +} + +/** + * Clear tool routing and return to home page + */ +export function clearToolRoute(): void { + const searchParams = new URLSearchParams(window.location.search); + searchParams.delete('tool'); + + const queryString = searchParams.toString(); + const url = '/' + (queryString ? `?${queryString}` : ''); + + window.history.replaceState(null, '', url); +} + +/** + * Get clean tool name for display purposes + */ +export function getToolDisplayName(toolKey: string): string { + const displayNames: Record = { + 'split': 'Split PDF', + 'merge': 'Merge PDF', + 'compress': 'Compress PDF', + 'convert': 'Convert PDF', + 'addPassword': 'Add Password', + 'changePermissions': 'Change Permissions', + 'sanitize': 'Sanitize PDF', + 'ocr': 'OCR PDF' + }; + + return displayNames[toolKey] || toolKey; +} + +/** + * Check if a mode is valid + */ +function isValidMode(mode: string): mode is ModeType { + const validModes: ModeType[] = [ + 'viewer', 'pageEditor', 'fileEditor', 'merge', 'split', + 'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize' + ]; + return validModes.includes(mode as ModeType); +} + +/** + * Generate shareable URL for current tool state + * Only generates URLs for actual tools, not internal UI modes + */ +export function generateShareableUrl(mode: ModeType, toolKey?: string): string { + const baseUrl = window.location.origin; + + // Don't generate URLs for internal UI modes + if (mode === 'viewer' || mode === 'fileEditor' || mode === 'pageEditor') { + return baseUrl; + } + + if (toolKey) { + const pathMappings: Record = { + 'split': '/split-pdf', + 'merge': '/merge-pdf', + 'compress': '/compress-pdf', + 'convert': '/convert-pdf', + 'addPassword': '/add-password-pdf', + 'changePermissions': '/change-permissions-pdf', + 'sanitize': '/sanitize-pdf', + 'ocr': '/ocr-pdf' + }; + + const path = pathMappings[toolKey] || `/${toolKey}`; + return `${baseUrl}${path}`; + } + + return baseUrl; +} \ No newline at end of file