From 949ffa01ad4b4f13632e8359c55fa606d3ca63aa Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:30:26 +0100 Subject: [PATCH] Feature/v2/file handling improvements (#4222) # Description of Changes A new universal file context rather than the splintered ones for the main views, tools and manager we had before (manager still has its own but its better integreated with the core context) File context has been split it into a handful of different files managing various file related issues separately to reduce the monolith - FileReducer.ts - State management fileActions.ts - File operations fileSelectors.ts - Data access patterns lifecycle.ts - Resource cleanup and memory management fileHooks.ts - React hooks interface contexts.ts - Context providers Improved thumbnail generation Improved indexxedb handling Stopped handling files as blobs were not necessary to improve performance A new library handling drag and drop https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes but I broke the old one with the new filecontext and it needed doing so it was a might as well) A new library handling virtualisation on page editor @tanstack/react-virtual, as above. Quickly ripped out the last remnants of the old URL params stuff and replaced with the beginnings of what will later become the new URL navigation system (for now it just restores the tool name in url behavior) Fixed selected file not regestered when opening a tool Fixed png thumbnails Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Reece Browne --- .claude/settings.local.json | 5 +- frontend/package-lock.json | 52 + frontend/package.json | 2 + .../public/locales/en-GB/translation.json | 4 +- frontend/public/thumbnailWorker.js | 157 --- frontend/src/App.tsx | 9 +- frontend/src/commands/pageCommands.ts | 30 +- .../src/components/FileCard.standalone.tsx | 136 -- frontend/src/components/FileManager.tsx | 45 +- .../src/components/fileEditor/FileEditor.tsx | 621 +++------ .../fileManager/CompactFileDetails.tsx | 6 +- .../components/fileManager/FileInfoCard.tsx | 4 +- .../components/fileManager/FileListArea.tsx | 4 +- .../components/fileManager/FileListItem.tsx | 15 +- .../history/FileOperationHistory.tsx | 8 +- frontend/src/components/layout/Workbench.tsx | 20 +- .../components/pageEditor/DragDropGrid.tsx | 203 +-- .../components/pageEditor/FileThumbnail.tsx | 193 ++- .../src/components/pageEditor/PageEditor.tsx | 1068 +++++++-------- .../components/pageEditor/PageThumbnail.tsx | 188 +-- frontend/src/components/shared/FileCard.tsx | 14 +- frontend/src/components/shared/FileGrid.tsx | 37 +- .../src/components/shared/FilePickerModal.tsx | 8 +- .../src/components/shared/FilePreview.tsx | 6 +- .../src/components/shared/LandingPage.tsx | 4 +- .../shared/NavigationWarningModal.tsx | 10 +- .../src/components/shared/TopControls.tsx | 78 +- .../shared/filePreview/DocumentThumbnail.tsx | 4 +- .../tools/convert/ConvertSettings.tsx | 36 +- frontend/src/components/viewer/Viewer.tsx | 34 +- frontend/src/contexts/FileContext.tsx | 1146 ++++------------- frontend/src/contexts/FileManagerContext.tsx | 90 +- .../src/contexts/FileSelectionContext.tsx | 100 -- frontend/src/contexts/FilesModalContext.tsx | 25 +- frontend/src/contexts/IndexedDBContext.tsx | 207 +++ frontend/src/contexts/NavigationContext.tsx | 231 ++++ frontend/src/contexts/SidebarContext.tsx | 14 +- frontend/src/contexts/ToolWorkflowContext.tsx | 8 +- frontend/src/contexts/file/FileReducer.ts | 240 ++++ frontend/src/contexts/file/contexts.ts | 13 + frontend/src/contexts/file/fileActions.ts | 370 ++++++ frontend/src/contexts/file/fileHooks.ts | 193 +++ frontend/src/contexts/file/fileSelectors.ts | 130 ++ frontend/src/contexts/file/lifecycle.ts | 190 +++ frontend/src/global.d.ts | 1 - .../tools/convert/useConvertParameters.ts | 134 +- .../hooks/tools/shared/useToolOperation.ts | 9 +- .../hooks/tools/shared/useToolResources.ts | 64 +- .../src/hooks/tools/shared/useToolState.ts | 2 + frontend/src/hooks/useFileHandler.ts | 35 +- frontend/src/hooks/useFileManager.ts | 166 ++- frontend/src/hooks/useIndexedDBThumbnail.ts | 55 +- frontend/src/hooks/useMemoryManagement.ts | 30 - frontend/src/hooks/usePDFProcessor.ts | 42 +- .../src/hooks/usePdfSignatureDetection.ts | 8 +- frontend/src/hooks/useThumbnailGeneration.ts | 187 ++- frontend/src/hooks/useUrlSync.ts | 125 ++ frontend/src/pages/HomePage.tsx | 46 +- .../services/enhancedPDFProcessingService.ts | 98 +- frontend/src/services/fileAnalyzer.ts | 11 +- .../src/services/fileOperationsService.ts | 194 --- .../src/services/fileProcessingService.ts | 209 +++ frontend/src/services/fileStorage.ts | 144 +-- frontend/src/services/indexedDBManager.ts | 227 ++++ frontend/src/services/pdfProcessingService.ts | 9 +- frontend/src/services/pdfWorkerManager.ts | 203 +++ .../services/thumbnailGenerationService.ts | 477 +++---- frontend/src/theme/mantineTheme.ts | 2 +- frontend/src/tools/AddPassword.tsx | 12 +- frontend/src/tools/AddWatermark.tsx | 11 +- frontend/src/tools/ChangePermissions.tsx | 10 +- frontend/src/tools/Compress.tsx | 11 +- frontend/src/tools/Convert.tsx | 14 +- frontend/src/tools/Merge.tsx | 149 --- frontend/src/tools/OCR.tsx | 10 +- frontend/src/tools/RemoveCertificateSign.tsx | 10 +- frontend/src/tools/RemovePassword.tsx | 11 +- frontend/src/tools/Repair.tsx | 10 +- frontend/src/tools/Sanitize.tsx | 10 +- frontend/src/tools/SingleLargePage.tsx | 10 +- frontend/src/tools/Split.tsx | 14 +- frontend/src/tools/UnlockPdfForms.tsx | 10 +- frontend/src/types/file.ts | 19 +- frontend/src/types/fileContext.ts | 301 +++-- frontend/src/types/tool.ts | 21 - frontend/src/utils/downloadUtils.ts | 14 +- frontend/src/utils/fileUtils.ts | 132 +- frontend/src/utils/storageUtils.ts | 3 +- frontend/src/utils/thumbnailUtils.ts | 182 ++- frontend/src/utils/urlRouting.ts | 180 +++ 90 files changed, 5416 insertions(+), 4164 deletions(-) delete mode 100644 frontend/public/thumbnailWorker.js delete mode 100644 frontend/src/components/FileCard.standalone.tsx delete mode 100644 frontend/src/contexts/FileSelectionContext.tsx create mode 100644 frontend/src/contexts/IndexedDBContext.tsx create mode 100644 frontend/src/contexts/NavigationContext.tsx create mode 100644 frontend/src/contexts/file/FileReducer.ts create mode 100644 frontend/src/contexts/file/contexts.ts create mode 100644 frontend/src/contexts/file/fileActions.ts create mode 100644 frontend/src/contexts/file/fileHooks.ts create mode 100644 frontend/src/contexts/file/fileSelectors.ts create mode 100644 frontend/src/contexts/file/lifecycle.ts delete mode 100644 frontend/src/hooks/useMemoryManagement.ts create mode 100644 frontend/src/hooks/useUrlSync.ts delete mode 100644 frontend/src/services/fileOperationsService.ts create mode 100644 frontend/src/services/fileProcessingService.ts create mode 100644 frontend/src/services/indexedDBManager.ts create mode 100644 frontend/src/services/pdfWorkerManager.ts delete mode 100644 frontend/src/tools/Merge.tsx create mode 100644 frontend/src/utils/urlRouting.ts 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