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 7d01af4f5..0a03fd01a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -366,14 +366,6 @@ "title": "Convert", "desc": "Convert files between different formats" }, - "imageToPDF": { - "title": "Image to PDF", - "desc": "Convert a image (PNG, JPEG, GIF) to PDF." - }, - "pdfToImage": { - "title": "PDF to Image", - "desc": "Convert a PDF to a image. (PNG, JPEG, GIF)" - }, "pdfOrganiser": { "title": "Organise", "desc": "Remove/Rearrange pages in any order" @@ -390,14 +382,6 @@ "title": "Add Watermark", "desc": "Add a custom watermark to your PDF document." }, - "permissions": { - "title": "Change Permissions", - "desc": "Change the permissions of your PDF document" - }, - "pageRemover": { - "title": "Remove", - "desc": "Delete unwanted pages from your PDF document." - }, "removePassword": { "title": "Remove Password", "desc": "Remove password protection from your PDF document." @@ -414,10 +398,6 @@ "title": "Change Metadata", "desc": "Change/Remove/Add metadata from a PDF document" }, - "fileToPDF": { - "title": "Convert file to PDF", - "desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)" - }, "ocr": { "title": "OCR / Cleanup scans", "desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text." @@ -426,30 +406,6 @@ "title": "Extract Images", "desc": "Extracts all images from a PDF and saves them to zip" }, - "pdfToPDFA": { - "title": "PDF to PDF/A", - "desc": "Convert PDF to PDF/A for long-term storage" - }, - "PDFToWord": { - "title": "PDF to Word", - "desc": "Convert PDF to Word formats (DOC, DOCX and ODT)" - }, - "PDFToPresentation": { - "title": "PDF to Presentation", - "desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)" - }, - "PDFToText": { - "title": "PDF to RTF (Text)", - "desc": "Convert PDF to Text or RTF format" - }, - "PDFToHTML": { - "title": "PDF to HTML", - "desc": "Convert PDF to HTML format" - }, - "PDFToXML": { - "title": "PDF to XML", - "desc": "Convert PDF to XML format" - }, "ScannerImageSplit": { "title": "Detect/Split Scanned photos", "desc": "Splits multiple photos from within a photo/PDF" @@ -518,34 +474,14 @@ "title": "Auto Split Pages", "desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code" }, - "sanitizePdf": { + "sanitize": { "title": "Sanitise", - "desc": "Remove scripts and other elements from PDF files" - }, - "URLToPDF": { - "title": "URL/Website To PDF", - "desc": "Converts any http(s)URL to PDF" - }, - "HTMLToPDF": { - "title": "HTML to PDF", - "desc": "Converts any HTML file or zip to PDF" - }, - "MarkdownToPDF": { - "title": "Markdown to PDF", - "desc": "Converts any Markdown file to PDF" - }, - "PDFToMarkdown": { - "title": "PDF to Markdown", - "desc": "Converts any PDF to Markdown" + "desc": "Remove potentially harmful elements from PDF files" }, "getPdfInfo": { "title": "Get ALL Info on PDF", "desc": "Grabs any and all information possible on PDFs" }, - "pageExtracter": { - "title": "Extract page(s)", - "desc": "Extracts select pages from PDF" - }, "pdfToSinglePage": { "title": "PDF to Single Large Page", "desc": "Merges all PDF pages into one large single page" @@ -562,14 +498,6 @@ "title": "Manual Redaction", "desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)" }, - "PDFToCSV": { - "title": "PDF to CSV", - "desc": "Extracts Tables from a PDF converting it to CSV" - }, - "split-by-size-or-count": { - "title": "Auto Split by Size/Count", - "desc": "Split a single PDF into multiple documents based on size, page count, or document count" - }, "overlay-pdfs": { "title": "Overlay PDFs", "desc": "Overlays PDFs on-top of another PDF" @@ -625,6 +553,54 @@ "reorganizePages": { "title": "Reorganize Pages", "desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control." + }, + "extractPages": { + "title": "Extract Pages", + "desc": "Extract specific pages from a PDF document" + }, + "removePages": { + "title": "Remove Pages", + "desc": "Remove specific pages from a PDF document" + }, + "removeImagePdf": { + "title": "Remove Image", + "desc": "Remove images from PDF documents" + }, + "autoSizeSplitPDF": { + "title": "Auto Split by Size/Count", + "desc": "Automatically split PDFs by file size or page count" + }, + "adjust-contrast": { + "title": "Adjust Colours/Contrast", + "desc": "Adjust colours and contrast of PDF documents" + }, + "replaceColorPdf": { + "title": "Replace & Invert Colour", + "desc": "Replace or invert colours in PDF documents" + }, + "devApi": { + "title": "API", + "desc": "Link to API documentation" + }, + "devFolderScanning": { + "title": "Automated Folder Scanning", + "desc": "Link to automated folder scanning guide" + }, + "devSsoGuide": { + "title": "SSO Guide", + "desc": "Link to SSO guide" + }, + "devAirgapped": { + "title": "Air-gapped Setup", + "desc": "Link to air-gapped setup guide" + }, + "addPassword": { + "title": "Add Password", + "desc": "Add password protection and restrictions to PDF files" + }, + "changePermissions": { + "title": "Change Permissions", + "desc": "Change document restrictions and permissions" } }, "viewPdf": { @@ -1011,7 +987,49 @@ "submit": "Change" }, "removePages": { - "tags": "Remove pages,delete pages" + "tags": "Remove pages,delete pages", + "title": "Remove Pages", + "pageNumbers": "Pages to Remove", + "pageNumbersPlaceholder": "e.g. 1,3,5-7", + "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "filenamePrefix": "pages_removed", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "settings": { + "title": "Page Selection" + }, + "error": { + "failed": "An error occurred whilst removing pages." + }, + "results": { + "title": "Page Removal Results" + }, + "submit": "Remove Pages" + }, + "pageSelection": { + "tooltip": { + "header": { + "title": "Page Selection Guide" + }, + "basic": { + "title": "Basic Usage", + "text": "Select specific pages from your PDF document using simple syntax.", + "bullet1": "Individual pages: 1,3,5", + "bullet2": "Page ranges: 3-6 or 10-15", + "bullet3": "All pages: all" + }, + "advanced": { + "title": "Advanced Features" + }, + "tips": { + "title": "Tips", + "text": "Keep these guidelines in mind:", + "bullet1": "Page numbers start from 1 (not 0)", + "bullet2": "Spaces are automatically removed", + "bullet3": "Invalid expressions are ignored" + } + } }, "compressPdfs": { "tags": "squish,small,tiny" @@ -1020,7 +1038,18 @@ "tags": "remove,delete,form,field,readonly", "title": "Remove Read-Only from Form Fields", "header": "Unlock PDF Forms", - "submit": "Remove" + "submit": "Unlock Forms", + "description": "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.", + "filenamePrefix": "unlocked_forms", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst unlocking PDF forms." + }, + "results": { + "title": "Unlocked Forms Results" + } }, "changeMetadata": { "tags": "Title,author,date,creation,time,publisher,producer,stats", @@ -1188,7 +1217,18 @@ "tags": "fix,restore,correction,recover", "title": "Repair", "header": "Repair PDFs", - "submit": "Repair" + "submit": "Repair", + "description": "This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.", + "filenamePrefix": "repaired", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst repairing the PDF." + }, + "results": { + "title": "Repair Results" + } }, "removeBlanks": { "tags": "cleanup,streamline,non-content,organize", @@ -1257,7 +1297,18 @@ "title": "Remove Certificate Signature", "header": "Remove the digital certificate from the PDF", "selectPDF": "Select a PDF file:", - "submit": "Remove Signature" + "submit": "Remove Signature", + "description": "This tool will remove digital certificate signatures from your PDF document.", + "filenamePrefix": "unsigned", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst removing certificate signatures." + }, + "results": { + "title": "Certificate Removal Results" + } }, "pageLayout": { "tags": "merge,composite,single-view,organize", @@ -1585,7 +1636,18 @@ "pdfToSinglePage": { "title": "PDF To Single Page", "header": "PDF To Single Page", - "submit": "Convert To Single Page" + "submit": "Convert To Single Page", + "description": "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.", + "filenamePrefix": "single_page", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst converting to single page." + }, + "results": { + "title": "Single Page Results" + } }, "pageExtracter": { "title": "Extract Pages", @@ -1863,18 +1925,23 @@ "noToolsFound": "No tools found", "allTools": "ALL TOOLS", "quickAccess": "QUICK ACCESS", + "categories": { + "standardTools": "Standard Tools", + "advancedTools": "Advanced Tools", + "recommendedTools": "Recommended Tools" + }, "subcategories": { - "Signing": "Signing", - "Document Security": "Document Security", - "Verification": "Verification", - "Document Review": "Document Review", - "Page Formatting": "Page Formatting", - "Extraction": "Extraction", - "Removal": "Removal", - "Automation": "Automation", - "General": "General", - "Advanced Formatting": "Advanced Formatting", - "Developer Tools": "Developer Tools" + "signing": "Signing", + "documentSecurity": "Document Security", + "verification": "Verification", + "documentReview": "Document Review", + "pageFormatting": "Page Formatting", + "extraction": "Extraction", + "removal": "Removal", + "automation": "Automation", + "general": "General", + "advancedFormatting": "Advanced Formatting", + "developerTools": "Developer Tools" } }, "quickAccess": { @@ -1905,7 +1972,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", @@ -1945,7 +2014,14 @@ "fileSize": "Size", "fileVersion": "Version", "totalSelected": "Total Selected", - "dropFilesHere": "Drop files here" + "dropFilesHere": "Drop files here", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "deleteSelected": "Delete Selected", + "downloadSelected": "Download Selected", + "selectedCount": "{{count}} selected", + "download": "Download", + "delete": "Delete" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index af7188944..358ccd53a 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -738,7 +738,72 @@ "submit": "Change" }, "removePages": { - "tags": "Remove pages,delete pages" + "tags": "Remove pages,delete pages", + "title": "Remove Pages", + "pageNumbers": "Pages to Remove", + "pageNumbersPlaceholder": "e.g. 1,3,5-7", + "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "filenamePrefix": "pages_removed", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "settings": { + "title": "Page Selection" + }, + "error": { + "failed": "An error occurred while removing pages." + }, + "results": { + "title": "Page Removal Results" + }, + "submit": "Remove Pages" + }, + "pageSelection": { + "tooltip": { + "header": { + "title": "Page Selection Guide" + }, + "basic": { + "title": "Basic Usage", + "text": "Select specific pages from your PDF document using simple syntax.", + "bullet1": "Individual pages: 1,3,5", + "bullet2": "Page ranges: 3-6 or 10-15", + "bullet3": "All pages: all" + }, + "advanced": { + "title": "Advanced Features", + "expandText": "▶ Show advanced options", + "collapseText": "▼ Hide advanced options", + "mathematical": { + "title": "Mathematical Functions", + "text": "Use mathematical expressions to select page patterns:", + "bullet1": "2n - all even pages (2, 4, 6, 8...)", + "bullet2": "2n+1 - all odd pages (1, 3, 5, 7...)", + "bullet3": "3n - every 3rd page (3, 6, 9, 12...)", + "bullet4": "4n-1 - pages 3, 7, 11, 15..." + }, + "ranges": { + "title": "Open-ended Ranges", + "text": "Select from a starting point to the end:", + "bullet1": "5- selects pages 5 to end of document", + "bullet2": "10- selects pages 10 to end" + }, + "combinations": { + "title": "Complex Combinations", + "text": "Combine different selection methods:", + "bullet1": "1,3-5,8,2n - pages 1, 3-5, 8, and all even pages", + "bullet2": "10-,2n+1 - pages 10 to end plus all odd pages", + "bullet3": "1-5,15-,3n - pages 1-5, 15 to end, and every 3rd page" + } + }, + "tips": { + "title": "Tips", + "text": "Keep these guidelines in mind:", + "bullet1": "Page numbers start from 1 (not 0)", + "bullet2": "Spaces are automatically removed", + "bullet3": "Invalid expressions are ignored" + } + } }, "compressPdfs": { "tags": "squish,small,tiny" @@ -747,7 +812,18 @@ "tags": "remove,delete,form,field,readonly", "title": "Remove Read-Only from Form Fields", "header": "Unlock PDF Forms", - "submit": "Remove" + "submit": "Unlock Forms", + "description": "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.", + "filenamePrefix": "unlocked_forms", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while unlocking PDF forms." + }, + "results": { + "title": "Unlocked Forms Results" + } }, "changeMetadata": { "tags": "Title,author,date,creation,time,publisher,producer,stats", @@ -915,7 +991,18 @@ "tags": "fix,restore,correction,recover", "title": "Repair", "header": "Repair PDFs", - "submit": "Repair" + "submit": "Repair", + "description": "This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.", + "filenamePrefix": "repaired", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while repairing the PDF." + }, + "results": { + "title": "Repair Results" + } }, "removeBlanks": { "tags": "cleanup,streamline,non-content,organize", @@ -984,7 +1071,18 @@ "title": "Remove Certificate Signature", "header": "Remove the digital certificate from the PDF", "selectPDF": "Select a PDF file:", - "submit": "Remove Signature" + "submit": "Remove Signature", + "description": "This tool will remove digital certificate signatures from your PDF document.", + "filenamePrefix": "unsigned", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while removing certificate signatures." + }, + "results": { + "title": "Certificate Removal Results" + } }, "pageLayout": { "tags": "merge,composite,single-view,organize", @@ -1312,7 +1410,18 @@ "pdfToSinglePage": { "title": "PDF To Single Page", "header": "PDF To Single Page", - "submit": "Convert To Single Page" + "submit": "Convert To Single Page", + "description": "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.", + "filenamePrefix": "single_page", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while converting to single page." + }, + "results": { + "title": "Single Page Results" + } }, "pageExtracter": { "title": "Extract Pages", 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 1d794ed87..df1197ab9 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,12 +1,12 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal } from '@mantine/core'; 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'; @@ -14,19 +14,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; @@ -53,33 +43,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 @@ -88,13 +70,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; @@ -108,115 +84,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 @@ -288,10 +179,7 @@ const FileEditor = ({ } } }; - - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); - + if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } @@ -300,7 +188,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) { @@ -314,7 +201,6 @@ const FileEditor = ({ }); } } else { - console.log(`Adding none PDF file: ${file.name} (${file.type})`); allExtractedFiles.push(file); } } @@ -343,9 +229,6 @@ const FileEditor = ({ } } }; - - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); } // Add files to context (they will be processed automatically) @@ -356,7 +239,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, @@ -366,220 +249,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, @@ -589,75 +389,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,7 +467,7 @@ const FileEditor = ({ - {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( + {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
📁 @@ -688,7 +475,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 ? ( @@ -722,88 +509,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/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx index be701ff20..8d1e32ffc 100644 --- a/frontend/src/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons'; import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; +import FileActions from './FileActions'; import HiddenFileInput from './HiddenFileInput'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; @@ -17,27 +18,27 @@ const DesktopLayout: React.FC = () => { return ( {/* Column 1: File Sources */} - - + {/* Column 2: File List */} - -
{ overflow: 'hidden' }}> {activeSource === 'recent' && ( -
- -
+ <> +
+ +
+
+ +
+ )} - +
0 ? modalHeight : '100%', backgroundColor: 'transparent', border: 'none', @@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
- + {/* Column 3: File Details */} - @@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => {
- + {/* Hidden file input for local file selection */} ); }; -export default DesktopLayout; \ No newline at end of file +export default DesktopLayout; diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/components/fileManager/FileActions.tsx new file mode 100644 index 000000000..7bc8d27bc --- /dev/null +++ b/frontend/src/components/fileManager/FileActions.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import SelectAllIcon from "@mui/icons-material/SelectAll"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import { useTranslation } from "react-i18next"; +import { useFileManagerContext } from "../../contexts/FileManagerContext"; + +const FileActions: React.FC = () => { + const { t } = useTranslation(); + const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = + useFileManagerContext(); + + const handleSelectAll = () => { + onSelectAll(); + }; + + const handleDeleteSelected = () => { + if (selectedFileIds.length > 0) { + onDeleteSelected(); + } + }; + + const handleDownloadSelected = () => { + if (selectedFileIds.length > 0) { + onDownloadSelected(); + } + }; + + // Only show actions if there are files + if (recentFiles.length === 0) { + return null; + } + + const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; + const hasSelection = selectedFileIds.length > 0; + + return ( +
+ {/* Left: Select All */} +
+ + + + + +
+ + {/* Center: Selected count */} +
+ {hasSelection && ( + + {t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })} + + )} +
+ + {/* Right: Delete and Download */} + + + + + + + + + + + + + +
+ ); +}; + +export default FileActions; 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 8e1975137..bb376765b 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -19,22 +19,23 @@ const FileListArea: React.FC = ({ activeSource, recentFiles, filteredFiles, - selectedFileIds, + selectedFilesSet, onFileSelect, onFileRemove, onFileDoubleClick, + onDownloadSingle, isFileSupported, } = useFileManagerContext(); const { t } = useTranslation(); if (activeSource === 'recent') { return ( - @@ -51,12 +52,13 @@ const FileListArea: React.FC = ({ ) : ( filteredFiles.map((file, index) => ( onFileSelect(file)} + onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} + onDownload={() => onDownloadSingle(file)} onDoubleClick={() => onFileDoubleClick(file)} /> )) @@ -77,4 +79,4 @@ const FileListArea: React.FC = ({ ); }; -export default FileListArea; \ No newline at end of file +export default FileListArea; diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 147133009..b04f9bc41 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -1,40 +1,54 @@ import React, { useState } from 'react'; -import { Group, Box, Text, ActionIcon, Checkbox, Divider } 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: () => void; + onSelect: (shiftKey?: boolean) => void; onRemove: () => void; + onDownload?: () => void; onDoubleClick?: () => void; isLast?: boolean; } -const FileListItem: React.FC = ({ - file, - isSelected, - isSupported, - onSelect, - onRemove, +const FileListItem: React.FC = ({ + file, + isSelected, + isSupported, + onSelect, + onRemove, + onDownload, onDoubleClick }) => { const [isHovered, setIsHovered] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { t } = useTranslation(); + + // Keep item in hovered state if menu is open + const shouldShowHovered = isHovered || isMenuOpen; return ( <> - onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -54,26 +68,66 @@ const FileListItem: React.FC = ({ }} /> - + - {file.name} + + {file.name} + {file.isDraft && ( + + DRAFT + + )} + {getFileSize(file)} • {getFileDate(file)} - {/* Delete button - fades in/out on hover */} - { e.stopPropagation(); onRemove(); }} - style={{ - opacity: isHovered ? 1 : 0, - transform: isHovered ? 'scale(1)' : 'scale(0.8)', - transition: 'opacity 0.3s ease, transform 0.3s ease', - pointerEvents: isHovered ? 'auto' : 'none' - }} + + {/* Three dots menu - fades in/out on hover */} + setIsMenuOpen(true)} + onClose={() => setIsMenuOpen(false)} > - - + + e.stopPropagation()} + style={{ + opacity: shouldShowHovered ? 1 : 0, + transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: shouldShowHovered ? 'auto' : 'none' + }} + > + + + + + + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + > + {t('fileManager.download', 'Download')} + + )} + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t('fileManager.delete', 'Delete')} + + + { } @@ -81,4 +135,4 @@ const FileListItem: React.FC = ({ ); }; -export default FileListItem; \ No newline at end of file +export default FileListItem; diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx index 30d1ad6b9..5201aafb4 100644 --- a/frontend/src/components/fileManager/MobileLayout.tsx +++ b/frontend/src/components/fileManager/MobileLayout.tsx @@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons'; import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; +import FileActions from './FileActions'; import HiddenFileInput from './HiddenFileInput'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; @@ -22,10 +23,11 @@ const MobileLayout: React.FC = () => { // Estimate heights of fixed components const fileSourceHeight = '3rem'; // FileSourceButtons height const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height + const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom) const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height - const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps + const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps - return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`; + return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`; }; return ( @@ -51,12 +53,20 @@ const MobileLayout: React.FC = () => { minHeight: 0 }}> {activeSource === 'recent' && ( - - - + <> + + + + + + + )} 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 0b7393e00..a98b19b99 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 0a1829657..609a31e1a 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)} >
{ + // Hide broken image if blob URL was revoked + const img = e.target as HTMLImageElement; + img.style.display = 'none'; + }} style={{ maxWidth: '100%', maxHeight: '100%', @@ -194,20 +211,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 && ( @@ -271,40 +290,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 && ( @@ -370,20 +355,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 759d20a28..dd2de63ce 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,7 +5,8 @@ import { Stack, Group, Portal } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; +import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; +import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { useUndoRedo } from "../../hooks/useUndoRedo"; import { @@ -16,9 +17,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'; @@ -50,87 +56,170 @@ const PageEditor = ({ onFunctionsReady, }: PageEditorProps) => { - // Get file context - const fileContext = useFileContext(); - - // 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; @@ -138,16 +227,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); @@ -161,17 +247,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[]) => { @@ -181,166 +272,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, - } = 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(() => { @@ -350,64 +452,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; @@ -423,14 +503,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); } } @@ -441,144 +521,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); } @@ -588,45 +639,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; @@ -645,6 +681,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) { @@ -670,6 +707,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) { @@ -720,13 +758,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)'; @@ -747,34 +788,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; @@ -824,12 +853,12 @@ const PageEditor = ({ ); executeCommand(command); - if (selectionMode || hasSelectedPages) { - setSelectedPages([]); + if (selectionMode) { + actions.setSelectedPages([]); } const pageCount = (selectionMode || hasSelectedPages) ? 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; @@ -865,6 +894,7 @@ const PageEditor = ({ }).filter(id => id) : []; + const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); setShowExportModal(true); @@ -883,6 +913,7 @@ const PageEditor = ({ }).filter(id => id) : []; + const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); if (errors.length > 0) { setStatus(errors.join(', ')); @@ -917,6 +948,7 @@ const PageEditor = ({ } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Export failed'; setStatus(errorMessage); + setStatus(errorMessage); } finally { setExportLoading(false); } @@ -935,58 +967,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) { @@ -1001,38 +1055,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]); @@ -1040,12 +1103,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(() => { @@ -1060,17 +1123,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]); @@ -1104,7 +1165,7 @@ const PageEditor = ({ const displayedPages = displayDocument?.pages || []; return ( - + {showEmpty && ( @@ -1118,9 +1179,10 @@ const PageEditor = ({ )} {showLoading && ( - + + {/* Progress indicator */} @@ -1147,12 +1209,13 @@ const PageEditor = ({
+
)} {displayDocument && ( - + {/* Enhanced Processing Status */} {globalProcessing && processingProgress < 100 && ( @@ -1183,41 +1246,25 @@ const PageEditor = ({ style={{ minWidth: 200 }} /> + ( { setShowExportModal(false); - const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0); + const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0); handleExport(selectedOnly); }} > @@ -1322,12 +1369,14 @@ const PageEditor = ({ We found unsaved changes from a previous session. Would you like to resume where you left off? + {foundDraft && ( Last saved: {new Date(foundDraft.timestamp).toLocaleString()} )} +