mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
resolve conflicts
This commit is contained in:
commit
9f929ae335
@ -11,8 +11,11 @@
|
||||
"Bash(npm test:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(sed:*)"
|
||||
],
|
||||
"deny": []
|
||||
"deny": [],
|
||||
"defaultMode": "acceptEdits"
|
||||
}
|
||||
}
|
||||
|
52
frontend/package-lock.json
generated
52
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 }
|
||||
});
|
||||
}
|
||||
};
|
@ -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() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<FilesModalProvider>
|
||||
<HomePage />
|
||||
</FilesModalProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<HomePage />
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
|
@ -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 {
|
||||
|
@ -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<FileCardProps> = ({ file, onRemove, onDoubleClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xs"
|
||||
style={{
|
||||
width: 225,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
cursor: onDoubleClick ? "pointer" : undefined
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
style={{
|
||||
border: "2px solid #e0e0e0",
|
||||
borderRadius: 8,
|
||||
width: 90,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
background: "#fafbfc",
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #ddd',
|
||||
borderTop: '2px solid #666',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: 8
|
||||
}} />
|
||||
<Text size="xs" c="dimmed">Generating...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge color="gray" variant="light" size="sm">
|
||||
{getFileSize(file)}
|
||||
</Badge>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{file.storedInIndexedDB && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
DB
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={onRemove}
|
||||
mt={4}
|
||||
>
|
||||
{t("delete", "Remove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileCard;
|
@ -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<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
|
||||
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<FileManagerProps> = ({ 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<FileManagerProps> = ({ 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<FileManagerProps> = ({ 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<FileManagerProps> = ({ selectedTool }) => {
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onNewFilesSelect={handleNewFileUpload}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
storeFile={storeFile}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
|
@ -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<FileItem[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(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<Map<string, HTMLDivElement>>(new Map());
|
||||
const lastActiveFilesRef = useRef<string[]>([]);
|
||||
const lastProcessedFilesRef = useRef<number>(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<string[]>([]);
|
||||
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<FileItem> => {
|
||||
// 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 = ({
|
||||
<Box p="md" pt="xl">
|
||||
|
||||
|
||||
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
|
||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
@ -688,7 +475,7 @@ const FileEditor = ({
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
|
||||
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
@ -722,88 +509,42 @@ const FileEditor = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Processing indicator */}
|
||||
{localLoading && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Loading files...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(conversionProgress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={localSelectedIds as any /* FIX ME */}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart as any /* FIX ME */}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter as any /* FIX ME */}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop as any /* FIX ME */}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile as any /* FIX ME */}
|
||||
dropTarget={dropTarget as any /* FIX ME */}
|
||||
multiItemDrag={multiFileDrag as any /* FIX ME */}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
<FileThumbnail
|
||||
file={file}
|
||||
index={index}
|
||||
totalFiles={files.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
isAnimating={isAnimating}
|
||||
fileRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(file, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '24rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{activeFileRecords.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
return (
|
||||
<FileThumbnail
|
||||
key={record.id}
|
||||
file={fileItem}
|
||||
index={index}
|
||||
totalFiles={activeFileRecords.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(fileItem.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
{/* Column 1: File Sources */}
|
||||
<Grid.Col span="content" p="lg" style={{
|
||||
minWidth: '13.625rem',
|
||||
width: '13.625rem',
|
||||
flexShrink: 0,
|
||||
<Grid.Col span="content" p="lg" style={{
|
||||
minWidth: '13.625rem',
|
||||
width: '13.625rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
}}>
|
||||
<FileSourceButtons />
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
{/* Column 2: File List */}
|
||||
<Grid.Col span="auto" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
<Grid.Col span="auto" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
flex: '1 1 0px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--bg-file-list)',
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
@ -45,18 +46,26 @@ const DesktopLayout: React.FC = () => {
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{activeSource === 'recent' && (
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<FileActions />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<FileListArea
|
||||
scrollAreaHeight={`calc(${modalHeight} )`}
|
||||
scrollAreaStyle={{
|
||||
scrollAreaStyle={{
|
||||
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
{/* Column 3: File Details */}
|
||||
<Grid.Col p="xl" span="content" style={{
|
||||
minWidth: '25rem',
|
||||
width: '25rem',
|
||||
flexShrink: 0,
|
||||
<Grid.Col p="xl" span="content" style={{
|
||||
minWidth: '25rem',
|
||||
width: '25rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
maxWidth: '18rem'
|
||||
}}>
|
||||
@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => {
|
||||
<FileDetails />
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
{/* Hidden file input for local file selection */}
|
||||
<HiddenFileInput />
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopLayout;
|
||||
export default DesktopLayout;
|
||||
|
115
frontend/src/components/fileManager/FileActions.tsx
Normal file
115
frontend/src/components/fileManager/FileActions.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: "3rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Left: Select All */}
|
||||
<div>
|
||||
<Tooltip
|
||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleSelectAll}
|
||||
disabled={filteredFiles.length === 0}
|
||||
radius="sm"
|
||||
>
|
||||
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Center: Selected count */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{hasSelection && (
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Delete and Download */}
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={!hasSelection}
|
||||
radius="sm"
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("fileManager.downloadSelected", "Download Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleDownloadSelected}
|
||||
disabled={!hasSelection}
|
||||
radius="sm"
|
||||
>
|
||||
<DownloadIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileActions;
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -19,22 +19,23 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
activeSource,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
selectedFilesSet,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (activeSource === 'recent') {
|
||||
return (
|
||||
<ScrollArea
|
||||
<ScrollArea
|
||||
h={scrollAreaHeight}
|
||||
style={{
|
||||
style={{
|
||||
...scrollAreaStyle
|
||||
}}
|
||||
type="always"
|
||||
type="always"
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
@ -51,12 +52,13 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
) : (
|
||||
filteredFiles.map((file, index) => (
|
||||
<FileListItem
|
||||
key={file.id || file.name}
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={selectedFileIds.includes(file.id || file.name)}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={() => 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<FileListAreaProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListArea;
|
||||
export default FileListArea;
|
||||
|
@ -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<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
isSupported,
|
||||
onSelect,
|
||||
onRemove,
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-gray-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: 'background-color 0.15s ease'
|
||||
transition: 'background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}
|
||||
onClick={onSelect}
|
||||
onClick={(e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@ -54,26 +68,66 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>{file.name}</Text>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
{file.isDraft && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
DRAFT
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||
</Box>
|
||||
{/* Delete button - fades in/out on hover */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => { 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 */}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => 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'
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onDownload && (
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload();
|
||||
}}
|
||||
>
|
||||
{t('fileManager.download', 'Download')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t('fileManager.delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Box>
|
||||
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||
@ -81,4 +135,4 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListItem;
|
||||
export default FileListItem;
|
||||
|
@ -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' && (
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</Box>
|
||||
<>
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</Box>
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<FileActions />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
|
@ -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<FileOperationHistoryProps> = ({
|
||||
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();
|
||||
|
@ -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 (
|
||||
<LandingPage
|
||||
/>
|
||||
@ -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 */}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView as any /* FIX ME */}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
|
||||
|
@ -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<T extends DragDropItem> {
|
||||
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<Map<string, HTMLDivElement>>) => 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 = <T extends DragDropItem>({
|
||||
@ -32,104 +24,129 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
selectedItems,
|
||||
selectionMode,
|
||||
isAnimating,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onEndZoneDragEnter,
|
||||
onReorderPages,
|
||||
renderItem,
|
||||
renderSplitMarker,
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
multiItemDrag,
|
||||
dragPosition,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Global drag cleanup
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<Box>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '1.5rem',
|
||||
justifyContent: 'flex-start',
|
||||
paddingBottom: '100px',
|
||||
// Performance optimizations for smooth scrolling
|
||||
willChange: 'scroll-position',
|
||||
transform: 'translateZ(0)', // Force hardware acceleration
|
||||
backfaceVisibility: 'hidden',
|
||||
// Use containment for better rendering performance
|
||||
contain: 'layout style paint',
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Split marker */}
|
||||
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
|
||||
|
||||
{/* Item */}
|
||||
{renderItem(item, index, itemRefs)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* End drop zone */}
|
||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
data-drop-zone="end"
|
||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
||||
dropTarget === 'end'
|
||||
? 'ring-2 ring-green-500 bg-green-50'
|
||||
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
style={{ borderRadius: '12px' }}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={onEndZoneDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, 'end')}
|
||||
>
|
||||
<div className="text-gray-500 text-sm text-center font-medium">
|
||||
Drop here to<br />move to end
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||
const rowItems = items.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Split marker */}
|
||||
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Multi-item drag indicator */}
|
||||
{multiItemDrag && dragPosition && (
|
||||
<div
|
||||
className={styles.multiDragIndicator}
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
}}
|
||||
>
|
||||
{multiItemDrag.count} items
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -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<Map<string, HTMLDivElement>>;
|
||||
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<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
ref={(el) => {
|
||||
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)}
|
||||
>
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
@ -185,6 +196,12 @@ const FileThumbnail = ({
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
onError={(e) => {
|
||||
// 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 = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page count badge */}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{file.pageCount} pages
|
||||
</Badge>
|
||||
{/* Page count badge - only show for PDFs */}
|
||||
{file.pageCount > 0 && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Unsupported badge */}
|
||||
{!isSupported && (
|
||||
@ -271,40 +290,6 @@ const FileThumbnail = ({
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{!toolMode && isSupported && (
|
||||
<>
|
||||
<Tooltip label="View File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
onSetStatus(`Opened ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label="View History">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowHistory(true);
|
||||
onSetStatus(`Viewing history for ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<HistoryIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{actualFile && (
|
||||
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
||||
@ -370,20 +355,6 @@ const FileThumbnail = ({
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* History Modal */}
|
||||
<Modal
|
||||
opened={showHistory}
|
||||
onClose={() => setShowHistory(false)}
|
||||
title={`Operation History - ${file.name}`}
|
||||
size="lg"
|
||||
scrollAreaComponent={'div' as any}
|
||||
>
|
||||
<FileOperationHistory
|
||||
fileId={file.name}
|
||||
showOnlyApplied={true}
|
||||
maxHeight={500}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -6,20 +6,13 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
||||
import { Command } from '../../hooks/useUndoRedo';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||
import styles from './PageEditor.module.css';
|
||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||
|
||||
// Ensure PDF.js worker is available
|
||||
if (!GlobalWorkerOptions.workerSrc) {
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js');
|
||||
} else {
|
||||
console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc);
|
||||
}
|
||||
|
||||
interface PageThumbnailProps {
|
||||
page: PDFPage;
|
||||
@ -28,22 +21,15 @@ interface PageThumbnailProps {
|
||||
originalFile?: File; // For lazy thumbnail generation
|
||||
selectedPages: number[];
|
||||
selectionMode: boolean;
|
||||
draggedPage: number | null;
|
||||
dropTarget: number | 'end' | null;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (pageNumber: number) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (pageNumber: number) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, pageNumber: number) => void;
|
||||
onTogglePage: (pageNumber: number) => void;
|
||||
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
||||
onExecuteCommand: (command: Command) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (pageNumber: number | null) => void;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||
RotatePagesCommand: typeof RotatePagesCommand;
|
||||
DeletePagesCommand: typeof DeletePagesCommand;
|
||||
ToggleSplitCommand: typeof ToggleSplitCommand;
|
||||
@ -58,22 +44,15 @@ const PageThumbnail = React.memo(({
|
||||
originalFile,
|
||||
selectedPages,
|
||||
selectionMode,
|
||||
draggedPage,
|
||||
dropTarget,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
pageRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onTogglePage,
|
||||
onAnimateReorder,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
onSetMovingPage,
|
||||
onReorderPages,
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ToggleSplitCommand,
|
||||
@ -81,51 +60,122 @@ const PageThumbnail = React.memo(({
|
||||
setPdfDocument,
|
||||
}: PageThumbnailProps) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const { state, selectors } = useFileState();
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
|
||||
// Update thumbnail URL when page prop changes
|
||||
// Update thumbnail URL when page prop changes - prevent redundant updates
|
||||
useEffect(() => {
|
||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
||||
setThumbnailUrl(page.thumbnail);
|
||||
}
|
||||
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
|
||||
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
|
||||
|
||||
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
|
||||
// Request thumbnail generation if not available (optimized for performance)
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl) {
|
||||
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
|
||||
return; // Skip if we already have a thumbnail
|
||||
if (thumbnailUrl || !originalFile) {
|
||||
return; // Skip if we already have a thumbnail or no original file
|
||||
}
|
||||
|
||||
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
|
||||
// Check cache first without async call
|
||||
const cachedThumbnail = getThumbnailFromCache(page.id);
|
||||
if (cachedThumbnail) {
|
||||
setThumbnailUrl(cachedThumbnail);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleThumbnailReady = (event: CustomEvent) => {
|
||||
const { pageNumber, thumbnail, pageId } = event.detail;
|
||||
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
|
||||
let cancelled = false;
|
||||
|
||||
if (pageNumber === page.pageNumber && pageId === page.id) {
|
||||
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
|
||||
setThumbnailUrl(thumbnail);
|
||||
const loadThumbnail = async () => {
|
||||
try {
|
||||
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber);
|
||||
|
||||
// Only update if component is still mounted and we got a result
|
||||
if (!cancelled && thumbnail) {
|
||||
setThumbnailUrl(thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
||||
loadThumbnail();
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
|
||||
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page.pageNumber, page.id, thumbnailUrl]);
|
||||
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
|
||||
|
||||
|
||||
// Register this component with pageRefs for animations
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
|
||||
? selectedPages
|
||||
: [page.pageNumber]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
|
||||
? selectedPages
|
||||
: undefined;
|
||||
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: ({ source }) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, pageRefs]);
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -147,25 +197,13 @@ const PageThumbnail = React.memo(({
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(page.pageNumber)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(page.pageNumber)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, page.pageNumber)}
|
||||
draggable={false}
|
||||
>
|
||||
{
|
||||
<div
|
||||
@ -188,7 +226,6 @@ const PageThumbnail = React.memo(({
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
console.log('📸 Checkbox clicked for page', page.pageNumber);
|
||||
e.stopPropagation();
|
||||
onTogglePage(page.pageNumber);
|
||||
}}
|
||||
@ -203,7 +240,7 @@ const PageThumbnail = React.memo(({
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]">
|
||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@ -221,6 +258,7 @@ const PageThumbnail = React.memo(({
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@ -230,11 +268,6 @@ const PageThumbnail = React.memo(({
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
) : isLoadingThumbnail ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed" mt={4}>Loading...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text size="lg" c="dimmed">📄</Text>
|
||||
@ -407,30 +440,25 @@ const PageThumbnail = React.memo(({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragIndicatorIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
fontSize: 16,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Helper for shallow array comparison
|
||||
const arraysEqual = (a: number[], b: number[]) => {
|
||||
return a.length === b.length && a.every((val, i) => val === b[i]);
|
||||
};
|
||||
|
||||
// Only re-render if essential props change
|
||||
return (
|
||||
prevProps.page.id === nextProps.page.id &&
|
||||
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
||||
prevProps.page.rotation === nextProps.page.rotation &&
|
||||
prevProps.page.thumbnail === nextProps.page.thumbnail &&
|
||||
prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
|
||||
// Shallow compare selectedPages array for better stability
|
||||
(prevProps.selectedPages === nextProps.selectedPages ||
|
||||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
|
||||
prevProps.selectionMode === nextProps.selectionMode &&
|
||||
prevProps.draggedPage === nextProps.draggedPage &&
|
||||
prevProps.dropTarget === nextProps.dropTarget &&
|
||||
prevProps.movingPage === nextProps.movingPage &&
|
||||
prevProps.isAnimating === nextProps.isAnimating
|
||||
);
|
||||
|
@ -6,12 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
file: File;
|
||||
record?: FileRecord;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
@ -21,9 +22,12 @@ interface FileCardProps {
|
||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||
}
|
||||
|
||||
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
@ -173,7 +177,7 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{file.storedInIndexedDB && (
|
||||
{record?.id && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
|
@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
import FileCard from "./FileCard";
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
|
||||
interface FileGridProps {
|
||||
files: FileWithUrl[];
|
||||
files: Array<{ file: File; record?: FileRecord }>;
|
||||
onRemove?: (index: number) => void;
|
||||
onDoubleClick?: (file: FileWithUrl) => void;
|
||||
onView?: (file: FileWithUrl) => void;
|
||||
onEdit?: (file: FileWithUrl) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onSelect?: (fileId: string) => void;
|
||||
selectedFiles?: string[];
|
||||
showSearch?: boolean;
|
||||
@ -46,19 +46,19 @@ const FileGrid = ({
|
||||
const [sortBy, setSortBy] = useState<SortOption>('date');
|
||||
|
||||
// Filter files based on search term
|
||||
const filteredFiles = files.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const filteredFiles = files.filter(item =>
|
||||
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...filteredFiles].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (b.lastModified || 0) - (a.lastModified || 0);
|
||||
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
return a.file.name.localeCompare(b.file.name);
|
||||
case 'size':
|
||||
return (b.size || 0) - (a.size || 0);
|
||||
return (b.file.size || 0) - (a.file.size || 0);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@ -122,18 +122,19 @@ const FileGrid = ({
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles.map((file, idx) => {
|
||||
const fileId = file.id || file.name;
|
||||
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(file.name) : true;
|
||||
{displayFiles.map((item, idx) => {
|
||||
const fileId = item.record?.id || item.file.name;
|
||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={file}
|
||||
file={item.file}
|
||||
record={item.record}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
|
||||
onView={onView && supported ? () => onView(file) : undefined}
|
||||
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
||||
onView={onView && supported ? () => onView(item) : undefined}
|
||||
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
|
||||
isSelected={selectedFiles.includes(fileId)}
|
||||
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
|
||||
isSupported={supported}
|
||||
|
@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';
|
||||
interface FilePickerModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
storedFiles: any[]; // Files from storage (FileWithUrl format)
|
||||
storedFiles: any[]; // Files from storage (various formats supported)
|
||||
onSelectFiles: (selectedFiles: File[]) => void;
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ const FilePickerModal = ({
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedFileIds(storedFiles.map(f => f.id || f.name));
|
||||
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
@ -57,7 +57,7 @@ const FilePickerModal = ({
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const selectedFiles = storedFiles.filter(f =>
|
||||
selectedFileIds.includes(f.id || f.name)
|
||||
selectedFileIds.includes(f.id)
|
||||
);
|
||||
|
||||
// Convert stored files to File objects
|
||||
@ -154,7 +154,7 @@ const FilePickerModal = ({
|
||||
<ScrollArea.Autosize mah={400}>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{storedFiles.map((file) => {
|
||||
const fileId = file.id || file.name;
|
||||
const fileId = file.id;
|
||||
const isSelected = selectedFileIds.includes(fileId);
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { FileWithUrl } from '../../types/file';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||
import DocumentStack from './filePreview/DocumentStack';
|
||||
import HoverOverlay from './filePreview/HoverOverlay';
|
||||
@ -8,7 +8,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
// Core file data
|
||||
file: File | FileWithUrl | null;
|
||||
file: File | FileMetadata | null;
|
||||
thumbnail?: string | null;
|
||||
|
||||
// Optional features
|
||||
@ -21,7 +21,7 @@ export interface FilePreviewProps {
|
||||
isAnimating?: boolean;
|
||||
|
||||
// Event handlers
|
||||
onFileClick?: (file: File | FileWithUrl | null) => void;
|
||||
onFileClick?: (file: File | FileMetadata | null) => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ const LandingPage = () => {
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["*/*"] as any}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95vh]"
|
||||
style={{
|
||||
@ -125,7 +125,7 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="*/*"
|
||||
accept=".pdf,.zip"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
@ -11,13 +11,13 @@ const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
const {
|
||||
showNavigationWarning,
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
confirmNavigation,
|
||||
cancelNavigation,
|
||||
confirmNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useFileContext();
|
||||
} = useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
|
@ -13,9 +13,9 @@ import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
getActiveNavButton,
|
||||
} from './quickAccessBar/QuickAccessBar';
|
||||
|
||||
@ -39,7 +39,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
|
||||
|
||||
const buttonConfigs: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
@ -226,4 +226,4 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
);
|
||||
});
|
||||
|
||||
export default QuickAccessBar;
|
||||
export default QuickAccessBar;
|
||||
|
@ -4,7 +4,8 @@ import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageSelector from '../shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
@ -23,17 +24,16 @@ export default function RightRail() {
|
||||
// CSV input state for page selection
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
|
||||
// File/page selection handlers that adapt to current view
|
||||
const {
|
||||
currentView,
|
||||
activeFiles,
|
||||
processedFiles,
|
||||
selectedFileIds,
|
||||
selectedPageNumbers,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
removeFiles
|
||||
} = useFileContext();
|
||||
// Navigation view
|
||||
const { currentMode: currentView } = useNavigationState();
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const fileRecords = selectors.getFileRecords();
|
||||
|
||||
// Compute selection state and total items
|
||||
const getSelectionState = useCallback(() => {
|
||||
@ -45,48 +45,43 @@ export default function RightRail() {
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
let totalItems = 0;
|
||||
if (activeFiles.length === 1) {
|
||||
const pf = processedFiles.get(activeFiles[0]);
|
||||
totalItems = (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||||
} else if (activeFiles.length > 1) {
|
||||
activeFiles.forEach(file => {
|
||||
const pf = processedFiles.get(file);
|
||||
totalItems += (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||||
});
|
||||
}
|
||||
const selectedCount = selectedPageNumbers.length;
|
||||
fileRecords.forEach(rec => {
|
||||
const pf = rec.processedFile;
|
||||
if (pf) {
|
||||
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
|
||||
}
|
||||
});
|
||||
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
return { totalItems: 0, selectedCount: 0 };
|
||||
}, [currentView, activeFiles, processedFiles, selectedFileIds, selectedPageNumbers]);
|
||||
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
const allIds = activeFiles.map(f => (f as any).id || f.name);
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
let totalPages = 0;
|
||||
if (activeFiles.length === 1) {
|
||||
const pf = processedFiles.get(activeFiles[0]);
|
||||
totalPages = (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||||
} else if (activeFiles.length > 1) {
|
||||
activeFiles.forEach(file => {
|
||||
const pf = processedFiles.get(file);
|
||||
totalPages += (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||||
});
|
||||
}
|
||||
fileRecords.forEach(rec => {
|
||||
const pf = rec.processedFile;
|
||||
if (pf) {
|
||||
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
|
||||
}
|
||||
});
|
||||
|
||||
if (totalPages > 0) {
|
||||
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
|
||||
}
|
||||
}
|
||||
}, [currentView, activeFiles, processedFiles, setSelectedFiles, setSelectedPages]);
|
||||
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
@ -101,9 +96,7 @@ export default function RightRail() {
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedCount > 0
|
||||
? activeFiles.filter(f => selectedFileIds.includes((f as any).id || f.name))
|
||||
: activeFiles;
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
@ -118,23 +111,18 @@ export default function RightRail() {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, selectedCount, activeFiles, selectedFileIds, pageEditorFunctions]);
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
if (selectedCount === 0) return;
|
||||
|
||||
const fileIdsToClose = activeFiles.filter(f => selectedFileIds.includes((f as any).id || f.name))
|
||||
.map(f => (f as any).id || f.name);
|
||||
|
||||
if (fileIdsToClose.length === 0) return;
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
// Close only selected files (do not delete from storage)
|
||||
removeFiles(fileIdsToClose, false);
|
||||
removeFiles(selectedFileIds, false);
|
||||
|
||||
// Update selection state to remove closed ids
|
||||
setSelectedFiles(selectedFileIds.filter(id => !fileIdsToClose.includes(id)));
|
||||
}, [currentView, selectedCount, activeFiles, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
// Clear selection after closing
|
||||
setSelectedFiles([]);
|
||||
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
// CSV parsing functions for page selection
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
@ -167,7 +155,9 @@ export default function RightRail() {
|
||||
|
||||
// Sync csvInput with selectedPageNumbers changes
|
||||
useEffect(() => {
|
||||
const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b);
|
||||
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
|
||||
? [...selectedPageNumbers].sort((a, b) => a - b)
|
||||
: [];
|
||||
const newCsvInput = sortedPageNumbers.join(', ');
|
||||
setCsvInput(newCsvInput);
|
||||
}, [selectedPageNumbers]);
|
||||
@ -285,7 +275,7 @@ export default function RightRail() {
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedPageNumbers}
|
||||
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
@ -307,7 +297,7 @@ export default function RightRail() {
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => pageEditorFunctions?.handleDelete?.()}
|
||||
disabled={!pageControlsVisible || selectedCount === 0}
|
||||
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
|
||||
>
|
||||
<span className="material-symbols-rounded">delete</span>
|
||||
</ActionIcon>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, Image } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
import { FileMetadata } from '../../../types/file';
|
||||
|
||||
export interface DocumentThumbnailProps {
|
||||
file: File | FileWithUrl | null;
|
||||
file: File | FileMetadata | null;
|
||||
thumbnail?: string | null;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Stack, Text } from '@mantine/core';
|
||||
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||
import ToolButton from './toolPicker/ToolButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolSections } from '../../hooks/useToolSections';
|
||||
@ -23,8 +23,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
||||
return (
|
||||
<Stack p="sm" gap="xs">
|
||||
{searchGroups.map(group => (
|
||||
<Box key={group.subcategory} w="100%">
|
||||
<SubcategoryHeader label={t(`toolPicker.subcategories.${group.subcategory}`, group.subcategory)} />
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
|
||||
<Stack gap="xs">
|
||||
{group.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
|
@ -1,12 +1,13 @@
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import { Box, Text, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import { getSubcategoryLabel, ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import ToolButton from "./toolPicker/ToolButton";
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import { SubcategoryGroup, useToolSections } from "../../hooks/useToolSections";
|
||||
import SubcategoryHeader from "./shared/SubcategoryHeader";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@ -17,14 +18,15 @@ interface ToolPickerProps {
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
const renderToolButtons = (
|
||||
subcategory: any,
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategory} w="100%">
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={subcategory.subcategory} />
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
|
||||
@ -69,11 +71,11 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
|
||||
const quickSection = useMemo(
|
||||
() => visibleSections.find(s => (s as any).key === 'quick'),
|
||||
() => visibleSections.find(s => s.key === 'quick'),
|
||||
[visibleSections]
|
||||
);
|
||||
const allSection = useMemo(
|
||||
() => visibleSections.find(s => (s as any).key === 'all'),
|
||||
() => visibleSections.find(s => s.key === 'all'),
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
@ -121,7 +123,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
{searchGroups.length === 0 ? (
|
||||
<NoToolsFound />
|
||||
) : (
|
||||
searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect))
|
||||
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect))
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
@ -164,8 +166,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
|
||||
<Box ref={quickAccessRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{quickSection?.subcategories.map(sc =>
|
||||
renderToolButtons(sc, selectedToolKey, onSelect, false)
|
||||
{quickSection?.subcategories.map(sc =>
|
||||
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@ -210,8 +212,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
|
||||
<Box ref={allToolsRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{allSection?.subcategories.map(sc =>
|
||||
renderToolButtons(sc, selectedToolKey, onSelect, true)
|
||||
{allSection?.subcategories.map(sc =>
|
||||
renderToolButtons(t, sc, selectedToolKey, onSelect, true)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
|
||||
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
|
||||
|
||||
interface CompressSettingsProps {
|
||||
parameters: CompressParameters;
|
||||
|
@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
|
||||
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
|
||||
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
|
||||
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
|
||||
import { useFileContext } from "../../../contexts/FileContext";
|
||||
import { useFileSelection } from "../../../contexts/FileContext";
|
||||
import { useFileState } from "../../../contexts/FileContext";
|
||||
import { detectFileExtension } from "../../../utils/fileUtils";
|
||||
import GroupedFormatDropdown from "./GroupedFormatDropdown";
|
||||
import ConvertToImageSettings from "./ConvertToImageSettings";
|
||||
@ -41,8 +41,9 @@ const ConvertSettings = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { setSelectedFiles } = useFileSelectionActions();
|
||||
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
const { state, selectors } = useFileState();
|
||||
const activeFiles = state.files.ids;
|
||||
|
||||
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
||||
|
||||
@ -85,9 +86,9 @@ const ConvertSettings = ({
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
@ -96,7 +97,7 @@ const ConvertSettings = ({
|
||||
...option,
|
||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||
}));
|
||||
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
|
||||
const resetParametersToDefaults = () => {
|
||||
onParameterChange('imageOptions', {
|
||||
@ -127,7 +128,8 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
const filterFilesByExtension = (extension: string) => {
|
||||
return activeFiles.filter(file => {
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
||||
return files.filter(file => {
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
|
||||
if (extension === 'any') {
|
||||
@ -141,9 +143,21 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
const updateFileSelection = (files: File[]) => {
|
||||
setSelectedFiles(files);
|
||||
const fileIds = files.map(file => (file as any).id || file.name);
|
||||
setContextSelectedFiles(fileIds);
|
||||
// Map File objects to their actual IDs in FileContext
|
||||
const fileIds = files.map(file => {
|
||||
// Find the file ID by matching file properties
|
||||
const fileRecord = state.files.ids
|
||||
.map(id => selectors.getFileRecord(id))
|
||||
.find(record =>
|
||||
record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified
|
||||
);
|
||||
return fileRecord?.id;
|
||||
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
|
||||
|
||||
setSelectedFiles(fileIds);
|
||||
};
|
||||
|
||||
const handleFromExtensionChange = (value: string) => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OCRParameters } from './OCRSettings';
|
||||
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
||||
|
||||
export interface AdvancedOCRParameters {
|
||||
advancedOptions: string[];
|
||||
|
@ -2,13 +2,7 @@ import React from 'react';
|
||||
import { Stack, Select, Text, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
|
||||
export interface OCRParameters {
|
||||
languages: string[];
|
||||
ocrType: string;
|
||||
ocrRenderType: string;
|
||||
additionalOptions: string[];
|
||||
}
|
||||
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
||||
|
||||
interface OCRSettingsProps {
|
||||
parameters: OCRParameters;
|
||||
@ -25,7 +19,7 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
||||
|
||||
<Select
|
||||
label={t('ocr.settings.ocrMode.label', 'OCR Mode')}
|
||||
value={parameters.ocrType}
|
||||
@ -51,4 +45,4 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default OCRSettings;
|
||||
export default OCRSettings;
|
||||
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RemoveCertificateSignParameters } from '../../../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters';
|
||||
|
||||
interface RemoveCertificateSignSettingsProps {
|
||||
parameters: RemoveCertificateSignParameters;
|
||||
onParameterChange: <K extends keyof RemoveCertificateSignParameters>(parameter: K, value: RemoveCertificateSignParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="remove-certificate-sign-settings">
|
||||
<p className="text-muted">
|
||||
{t('removeCertSign.description', 'This tool will remove digital certificate signatures from your PDF document.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoveCertificateSignSettings;
|
27
frontend/src/components/tools/repair/RepairSettings.tsx
Normal file
27
frontend/src/components/tools/repair/RepairSettings.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RepairParameters } from '../../../hooks/tools/repair/useRepairParameters';
|
||||
|
||||
interface RepairSettingsProps {
|
||||
parameters: RepairParameters;
|
||||
onParameterChange: <K extends keyof RepairParameters>(parameter: K, value: RepairParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RepairSettings: React.FC<RepairSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="repair-settings">
|
||||
<p className="text-muted">
|
||||
{t('repair.description', 'This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepairSettings;
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import React from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: File[];
|
||||
@ -8,20 +9,25 @@ export interface FileStatusIndicatorProps {
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
placeholder = "Select a PDF file in the main view to get started"
|
||||
placeholder,
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultPlaceholder = placeholder || t("files.placeholder", "Select a PDF file in the main view to get started");
|
||||
|
||||
// Only show content when no files are selected
|
||||
if (selectedFiles.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{placeholder}
|
||||
{defaultPlaceholder}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Return nothing when files are selected
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileStatusIndicator;
|
||||
export default FileStatusIndicator;
|
||||
|
@ -21,7 +21,7 @@ export function SuggestedToolsSection(): React.ReactElement {
|
||||
const IconComponent = tool.icon;
|
||||
return (
|
||||
<Card
|
||||
key={tool.name}
|
||||
key={tool.id}
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SingleLargePageParameters } from '../../../hooks/tools/singleLargePage/useSingleLargePageParameters';
|
||||
|
||||
interface SingleLargePageSettingsProps {
|
||||
parameters: SingleLargePageParameters;
|
||||
onParameterChange: <K extends keyof SingleLargePageParameters>(parameter: K, value: SingleLargePageParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="single-large-page-settings">
|
||||
<p className="text-muted">
|
||||
{t('pdfToSinglePage.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.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleLargePageSettings;
|
@ -14,9 +14,9 @@ interface ToolButtonProps {
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
||||
const handleClick = (id: string) => {
|
||||
if (tool.link) {
|
||||
// Open external link in new tab
|
||||
// Open external link in new tab
|
||||
window.open(tool.link, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
return;
|
||||
}
|
||||
// Normal tool selection
|
||||
onSelect(id);
|
||||
@ -47,4 +47,4 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolButton;
|
||||
export default ToolButton;
|
||||
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UnlockPdfFormsParameters } from '../../../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters';
|
||||
|
||||
interface UnlockPdfFormsSettingsProps {
|
||||
parameters: UnlockPdfFormsParameters;
|
||||
onParameterChange: <K extends keyof UnlockPdfFormsParameters>(parameter: K, value: UnlockPdfFormsParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="unlock-pdf-forms-settings">
|
||||
<p className="text-muted">
|
||||
{t('unlockPDFForms.description', 'This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnlockPdfFormsSettings;
|
62
frontend/src/components/tooltips/usePageSelectionTips.tsx
Normal file
62
frontend/src/components/tooltips/usePageSelectionTips.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
/**
|
||||
* Reusable tooltip for page selection functionality.
|
||||
* Can be used by any tool that uses the GeneralUtils.parsePageList syntax.
|
||||
*/
|
||||
export const usePageSelectionTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("pageSelection.tooltip.header.title", "Page Selection Guide")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
description: t("pageSelection.tooltip.description", "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.")
|
||||
},
|
||||
{
|
||||
title: t("pageSelection.tooltip.individual.title", "Individual Pages"),
|
||||
description: t("pageSelection.tooltip.individual.description", "Enter numbers separated by commas."),
|
||||
bullets: [
|
||||
t("pageSelection.tooltip.individual.bullet1", "<strong>1,3,5</strong> → selects pages 1, 3, 5"),
|
||||
t("pageSelection.tooltip.individual.bullet2", "<strong>2,7,12</strong> → selects pages 2, 7, 12")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("pageSelection.tooltip.ranges.title", "Page Ranges"),
|
||||
description: t("pageSelection.tooltip.ranges.description", "Use - for consecutive pages."),
|
||||
bullets: [
|
||||
t("pageSelection.tooltip.ranges.bullet1", "<strong>3-6</strong> → selects pages 3–6"),
|
||||
t("pageSelection.tooltip.ranges.bullet2", "<strong>10-15</strong> → selects pages 10–15"),
|
||||
t("pageSelection.tooltip.ranges.bullet3", "<strong>5-</strong> → selects pages 5 to end")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("pageSelection.tooltip.mathematical.title", "Mathematical Functions"),
|
||||
description: t("pageSelection.tooltip.mathematical.description", "Use n in formulas for patterns."),
|
||||
bullets: [
|
||||
t("pageSelection.tooltip.mathematical.bullet2", "<strong>2n-1</strong> → all odd pages (1, 3, 5…)"),
|
||||
t("pageSelection.tooltip.mathematical.bullet1", "<strong>2n</strong> → all even pages (2, 4, 6…)"),
|
||||
t("pageSelection.tooltip.mathematical.bullet3", "<strong>3n</strong> → every 3rd page (3, 6, 9…)"),
|
||||
t("pageSelection.tooltip.mathematical.bullet4", "<strong>4n-1</strong> → pages 3, 7, 11, 15…")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("pageSelection.tooltip.special.title", "Special Keywords"),
|
||||
bullets: [
|
||||
t("pageSelection.tooltip.special.bullet1", "<strong>all</strong> → selects all pages"),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("pageSelection.tooltip.complex.title", "Complex Combinations"),
|
||||
description: t("pageSelection.tooltip.complex.description", "Mix different types."),
|
||||
bullets: [
|
||||
t("pageSelection.tooltip.complex.bullet1", "<strong>1,3-5,8,2n</strong> → pages 1, 3–5, 8, plus evens"),
|
||||
t("pageSelection.tooltip.complex.bullet2", "<strong>10-,2n-1</strong> → from page 10 to end + odd pages")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
||||
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||
@ -13,10 +13,9 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { useFileContext } from "../../contexts/FileContext";
|
||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||
|
||||
// Lazy loading page image component
|
||||
interface LazyPageImageProps {
|
||||
@ -150,7 +149,15 @@ const Viewer = ({
|
||||
const theme = useMantineTheme();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const currentFile = useCurrentFile();
|
||||
|
||||
const getCurrentFile = () => currentFile.file;
|
||||
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
|
||||
const clearAllFiles = actions.clearAllFiles;
|
||||
const addFiles = actions.addFiles;
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Tab management for multiple files
|
||||
const [activeTab, setActiveTab] = useState<string>("0");
|
||||
@ -171,6 +178,10 @@ const Viewer = ({
|
||||
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
||||
|
||||
// Memoize setPageRef to prevent infinite re-renders
|
||||
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
|
||||
pageRefs.current[index] = ref;
|
||||
}, []);
|
||||
|
||||
// Get files with URLs for tabs - we'll need to create these individually
|
||||
const file0WithUrl = useFileWithUrl(activeFiles[0]);
|
||||
@ -385,7 +396,7 @@ const Viewer = ({
|
||||
throw new Error('No valid PDF source available');
|
||||
}
|
||||
|
||||
const pdf = await getDocument(pdfData).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(pdfData);
|
||||
pdfDocRef.current = pdf;
|
||||
setNumPages(pdf.numPages);
|
||||
if (!cancelled) {
|
||||
@ -406,6 +417,11 @@ const Viewer = ({
|
||||
cancelled = true;
|
||||
// Stop any ongoing preloading
|
||||
preloadingRef.current = false;
|
||||
// Cleanup PDF document using worker manager
|
||||
if (pdfDocRef.current) {
|
||||
pdfWorkerManager.destroyDocument(pdfDocRef.current);
|
||||
pdfDocRef.current = null;
|
||||
}
|
||||
// Cleanup ArrayBuffer reference to help garbage collection
|
||||
currentArrayBufferRef.current = null;
|
||||
};
|
||||
@ -461,7 +477,7 @@ const Viewer = ({
|
||||
>
|
||||
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
|
||||
<Tabs.List>
|
||||
{activeFiles.map((file, index) => (
|
||||
{activeFiles.map((file: any, index: number) => (
|
||||
<Tabs.Tab key={index} value={index.toString()}>
|
||||
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
|
||||
</Tabs.Tab>
|
||||
@ -494,7 +510,7 @@ const Viewer = ({
|
||||
isFirst={i === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
setPageRef={setPageRef}
|
||||
/>
|
||||
{i * 2 + 1 < numPages && (
|
||||
<LazyPageImage
|
||||
@ -504,7 +520,7 @@ const Viewer = ({
|
||||
isFirst={i === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
setPageRef={setPageRef}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@ -518,7 +534,7 @@ const Viewer = ({
|
||||
isFirst={idx === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
setPageRef={setPageRef}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { AddWatermarkParameters } from "../hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
|
||||
export interface AlphabetOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@ -13,16 +11,3 @@ export const alphabetOptions: AlphabetOption[] = [
|
||||
{ value: "chinese", label: "简体中文" },
|
||||
{ value: "thai", label: "ไทย" },
|
||||
];
|
||||
|
||||
export const defaultWatermarkParameters: AddWatermarkParameters = {
|
||||
watermarkType: undefined,
|
||||
watermarkText: '',
|
||||
fontSize: 12,
|
||||
rotation: 0,
|
||||
opacity: 50,
|
||||
widthSpacer: 50,
|
||||
heightSpacer: 50,
|
||||
alphabet: 'roman',
|
||||
customColor: '#d3d3d3',
|
||||
convertPDFToImage: false
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import { StoredFile } from '../services/fileStorage';
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
@ -8,22 +9,27 @@ interface FileManagerContextValue {
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: string[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
selectedFiles: FileMetadata[];
|
||||
filteredFiles: FileMetadata[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileWithUrl) => void;
|
||||
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
onFileDoubleClick: (file: FileMetadata) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: FileMetadata) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileWithUrl[];
|
||||
recentFiles: FileMetadata[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
@ -34,14 +40,14 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: FileWithUrl[];
|
||||
onFilesSelected: (files: FileWithUrl[]) => void;
|
||||
recentFiles: FileMetadata[];
|
||||
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files
|
||||
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
||||
onClose: () => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
isOpen: boolean;
|
||||
onFileRemove: (index: number) => void;
|
||||
modalHeight: string;
|
||||
storeFile: (file: File) => Promise<StoredFile>;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
}
|
||||
|
||||
@ -49,33 +55,40 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onNewFilesSelect,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
isOpen,
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
storeFile,
|
||||
refreshRecentFiles,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
|
||||
const filteredFiles = (recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
||||
|
||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||
(recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
||||
setActiveSource(source);
|
||||
if (source !== 'recent') {
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -83,19 +96,46 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||
setSelectedFileIds(prev => {
|
||||
if (file.id) {
|
||||
if (prev.includes(file.id)) {
|
||||
return prev.filter(id => id !== file.id);
|
||||
} else {
|
||||
return [...prev, file.id];
|
||||
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
if (shiftKey && lastClickedIndex !== null) {
|
||||
// Range selection with shift-click
|
||||
const startIndex = Math.min(lastClickedIndex, currentIndex);
|
||||
const endIndex = Math.max(lastClickedIndex, currentIndex);
|
||||
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
// Add all files in the range to selection
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const rangeFileId = filteredFiles[i]?.id;
|
||||
if (rangeFileId) {
|
||||
selectedSet.add(rangeFileId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
} else {
|
||||
// Normal click behavior - optimized with Set for O(1) lookup
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
if (selectedSet.has(fileId)) {
|
||||
selectedSet.delete(fileId);
|
||||
} else {
|
||||
selectedSet.add(fileId);
|
||||
}
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
|
||||
// Update last clicked index for future range selections
|
||||
setLastClickedIndex(currentIndex);
|
||||
}
|
||||
}, [filteredFiles, lastClickedIndex]);
|
||||
|
||||
const handleFileRemove = useCallback((index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
@ -105,7 +145,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onFileRemove(index);
|
||||
}, [filteredFiles, onFileRemove]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
||||
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onFilesSelected([file]);
|
||||
onClose();
|
||||
@ -127,22 +167,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
||||
const fileWithUrls = files.map(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
createdBlobUrls.current.add(url);
|
||||
|
||||
return {
|
||||
// No ID assigned here - FileContext will handle storage and ID assignment
|
||||
name: file.name,
|
||||
file,
|
||||
url,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
});
|
||||
|
||||
onFilesSelected(fileWithUrls as any /* FIX ME */);
|
||||
// For local file uploads, pass File objects directly to FileContext
|
||||
onNewFilesSelect(files);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@ -150,7 +176,72 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}
|
||||
event.target.value = '';
|
||||
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
if (allFilesSelected) {
|
||||
// Deselect all
|
||||
setSelectedFileIds([]);
|
||||
setLastClickedIndex(null);
|
||||
} else {
|
||||
// Select all filtered files
|
||||
setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean));
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [filteredFiles, selectedFileIds]);
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Get files to delete based on current filtered view
|
||||
const filesToDelete = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Delete files from storage
|
||||
for (const file of filesToDelete) {
|
||||
await fileStorage.deleteFile(file.id);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
setSelectedFileIds([]);
|
||||
|
||||
// Refresh the file list
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
|
||||
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Get selected files
|
||||
const selectedFilesToDownload = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Use generic download utility
|
||||
await downloadFiles(selectedFilesToDownload, {
|
||||
zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles]);
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
|
||||
try {
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
@ -169,10 +260,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
setActiveSource('recent');
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const contextValue: FileManagerContextValue = {
|
||||
const contextValue: FileManagerContextValue = useMemo(() => ({
|
||||
// State
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
@ -180,6 +272,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
@ -190,12 +283,37 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
onFileInputChange: handleFileInputChange,
|
||||
onSelectAll: handleSelectAll,
|
||||
onDeleteSelected: handleDeleteSelected,
|
||||
onDownloadSelected: handleDownloadSelected,
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
};
|
||||
}), [
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
searchTerm,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleFileDoubleClick,
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
handleFileInputChange,
|
||||
handleSelectAll,
|
||||
handleDeleteSelected,
|
||||
handleDownloadSelected,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FileManagerContext.Provider value={contextValue}>
|
||||
|
@ -1,100 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import {
|
||||
MaxFiles,
|
||||
FileSelectionContextValue
|
||||
} from '../types/tool';
|
||||
import { useFileContext } from './FileContext';
|
||||
|
||||
interface FileSelectionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
||||
|
||||
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
||||
const { activeFiles } = useFileContext();
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
||||
const [isToolMode, setIsToolMode] = useState<boolean>(false);
|
||||
|
||||
// Sync selected files with active files - remove any selected files that are no longer active
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
const activeFileSet = new Set(activeFiles);
|
||||
const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
|
||||
|
||||
if (validSelectedFiles.length !== selectedFiles.length) {
|
||||
setSelectedFiles(validSelectedFiles);
|
||||
}
|
||||
}
|
||||
}, [activeFiles, selectedFiles]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
}, []);
|
||||
|
||||
const selectionCount = selectedFiles.length;
|
||||
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
|
||||
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
|
||||
const isMultiFileMode = maxFiles !== 1;
|
||||
|
||||
const contextValue: FileSelectionContextValue = {
|
||||
selectedFiles,
|
||||
maxFiles,
|
||||
isToolMode,
|
||||
setSelectedFiles,
|
||||
setMaxFiles,
|
||||
setIsToolMode,
|
||||
clearSelection,
|
||||
canSelectMore,
|
||||
isAtLimit,
|
||||
selectionCount,
|
||||
isMultiFileMode
|
||||
};
|
||||
|
||||
return (
|
||||
<FileSelectionContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FileSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the file selection context.
|
||||
* Throws if used outside a <FileSelectionProvider>.
|
||||
*/
|
||||
export function useFileSelection(): FileSelectionContextValue {
|
||||
const context = useContext(FileSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileSelection must be used within a FileSelectionProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Returns only the file selection values relevant for tools (e.g. merge, split, etc.)
|
||||
// Use this in tool panels/components that need to know which files are selected and selection limits.
|
||||
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
|
||||
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
|
||||
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
|
||||
}
|
||||
|
||||
// Returns actions for manipulating file selection state.
|
||||
// Use this in components that need to update the selection, clear it, or change selection mode.
|
||||
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
|
||||
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
|
||||
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
|
||||
}
|
||||
|
||||
// Returns the raw file selection state (selected files, max files, tool mode).
|
||||
// Use this for low-level state access, e.g. in context-aware UI.
|
||||
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
|
||||
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
|
||||
return { selectedFiles, maxFiles, isToolMode };
|
||||
}
|
||||
|
||||
// Returns computed values derived from file selection state.
|
||||
// Use this for file selection UI logic (e.g. disabling buttons when at limit).
|
||||
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
|
||||
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
|
||||
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { FileMetadata } from '../types/file';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
@ -7,6 +8,7 @@ interface FilesModalContextType {
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
@ -14,7 +16,7 @@ interface FilesModalContextType {
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
||||
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
|
||||
@ -37,19 +39,34 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal]);
|
||||
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||
addStoredFiles(filesWithMetadata);
|
||||
closeFilesModal();
|
||||
}, [addStoredFiles, closeFilesModal]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
}, []);
|
||||
|
||||
const contextValue: FilesModalContextType = {
|
||||
const contextValue: FilesModalContextType = useMemo(() => ({
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onStoredFilesSelect: handleStoredFilesSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
};
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileSelect,
|
||||
handleFilesSelect,
|
||||
handleStoredFilesSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FilesModalContext.Provider value={contextValue}>
|
||||
|
207
frontend/src/contexts/IndexedDBContext.tsx
Normal file
207
frontend/src/contexts/IndexedDBContext.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
/**
|
||||
* IndexedDBContext - Clean persistence layer for file storage
|
||||
* Integrates with FileContext to provide transparent file persistence
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage, StoredFile } from '../services/fileStorage';
|
||||
import { FileId } from '../types/fileContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Utilities
|
||||
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
||||
|
||||
interface IndexedDBProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
// LRU File cache to avoid repeated ArrayBuffer→File conversions
|
||||
const fileCache = useRef(new Map<FileId, { file: File; lastAccessed: number }>());
|
||||
const MAX_CACHE_SIZE = 50; // Maximum number of files to cache
|
||||
|
||||
// LRU cache management
|
||||
const evictLRUEntries = useCallback(() => {
|
||||
if (fileCache.current.size <= MAX_CACHE_SIZE) return;
|
||||
|
||||
// Convert to array and sort by last accessed time (oldest first)
|
||||
const entries = Array.from(fileCache.current.entries())
|
||||
.sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed);
|
||||
|
||||
// Remove the least recently used entries
|
||||
const toRemove = entries.slice(0, fileCache.current.size - MAX_CACHE_SIZE);
|
||||
toRemove.forEach(([fileId]) => {
|
||||
fileCache.current.delete(fileId);
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB
|
||||
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
// Return metadata
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
thumbnail
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||
// Check cache first
|
||||
const cached = fileCache.current.get(fileId);
|
||||
if (cached) {
|
||||
// Update last accessed time for LRU
|
||||
cached.lastAccessed = Date.now();
|
||||
return cached.file;
|
||||
}
|
||||
|
||||
// Load from IndexedDB
|
||||
const storedFile = await fileStorage.getFile(fileId);
|
||||
if (!storedFile) return null;
|
||||
|
||||
// Reconstruct File object
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
|
||||
// Cache for future use with LRU eviction
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
return file;
|
||||
}, [evictLRUEntries]);
|
||||
|
||||
const loadMetadata = useCallback(async (fileId: FileId): Promise<FileMetadata | null> => {
|
||||
// Try to get from cache first (no IndexedDB hit)
|
||||
const cached = fileCache.current.get(fileId);
|
||||
if (cached) {
|
||||
const file = cached.file;
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
}
|
||||
|
||||
// Load metadata from IndexedDB (efficient - no data field)
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const fileMetadata = metadata.find(m => m.id === fileId);
|
||||
|
||||
if (!fileMetadata) return null;
|
||||
|
||||
return {
|
||||
id: fileMetadata.id,
|
||||
name: fileMetadata.name,
|
||||
type: fileMetadata.type,
|
||||
size: fileMetadata.size,
|
||||
lastModified: fileMetadata.lastModified,
|
||||
thumbnail: fileMetadata.thumbnail
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
|
||||
return metadata.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileIds.forEach(id => fileCache.current.delete(id));
|
||||
|
||||
// Remove from IndexedDB in parallel
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
// Clear cache
|
||||
fileCache.current.clear();
|
||||
|
||||
// Clear IndexedDB
|
||||
await fileStorage.clearAll();
|
||||
}, []);
|
||||
|
||||
const getStorageStats = useCallback(async () => {
|
||||
return await fileStorage.getStorageStats();
|
||||
}, []);
|
||||
|
||||
const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise<boolean> => {
|
||||
return await fileStorage.updateThumbnail(fileId, thumbnail);
|
||||
}, []);
|
||||
|
||||
const value: IndexedDBContextValue = {
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail
|
||||
};
|
||||
|
||||
return (
|
||||
<IndexedDBContext.Provider value={value}>
|
||||
{children}
|
||||
</IndexedDBContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useIndexedDB() {
|
||||
const context = useContext(IndexedDBContext);
|
||||
if (!context) {
|
||||
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
231
frontend/src/contexts/NavigationContext.tsx
Normal file
231
frontend/src/contexts/NavigationContext.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
|
||||
/**
|
||||
* NavigationContext - Complete navigation management system
|
||||
*
|
||||
* Handles navigation modes, navigation guards for unsaved changes,
|
||||
* and breadcrumb/history navigation. Separated from FileContext to
|
||||
* maintain clear separation of concerns.
|
||||
*/
|
||||
|
||||
// Navigation mode types - complete list to match fileContext.ts
|
||||
export type ModeType =
|
||||
| 'viewer'
|
||||
| 'pageEditor'
|
||||
| 'fileEditor'
|
||||
| 'merge'
|
||||
| 'split'
|
||||
| 'compress'
|
||||
| 'ocr'
|
||||
| 'convert'
|
||||
| 'sanitize'
|
||||
| 'addPassword'
|
||||
| 'changePermissions'
|
||||
| 'addWatermark'
|
||||
| 'removePassword'
|
||||
| 'single-large-page'
|
||||
| 'repair'
|
||||
| 'unlockPdfForms'
|
||||
| 'removeCertificateSign';
|
||||
|
||||
// Navigation state
|
||||
interface NavigationState {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
type NavigationAction =
|
||||
| { type: 'SET_MODE'; payload: { mode: ModeType } }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||
|
||||
// Navigation reducer
|
||||
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, currentMode: action.payload.mode };
|
||||
|
||||
case 'SET_UNSAVED_CHANGES':
|
||||
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
|
||||
|
||||
case 'SET_PENDING_NAVIGATION':
|
||||
return { ...state, pendingNavigation: action.payload.navigationFn };
|
||||
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return { ...state, showNavigationWarning: action.payload.show };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: NavigationState = {
|
||||
currentMode: 'pageEditor',
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
export interface NavigationContextActions {
|
||||
setMode: (mode: ModeType) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
showNavigationWarning: (show: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
}
|
||||
|
||||
// Split context values
|
||||
export interface NavigationContextStateValue {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
||||
|
||||
export interface NavigationContextActionsValue {
|
||||
actions: NavigationContextActions;
|
||||
}
|
||||
|
||||
// Create contexts
|
||||
const NavigationStateContext = createContext<NavigationContextStateValue | undefined>(undefined);
|
||||
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export const NavigationProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
enableUrlSync?: boolean;
|
||||
}> = ({ children, enableUrlSync = true }) => {
|
||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||
|
||||
const actions: NavigationContextActions = {
|
||||
setMode: useCallback((mode: ModeType) => {
|
||||
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||
}, []),
|
||||
|
||||
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []),
|
||||
|
||||
showNavigationWarning: useCallback((show: boolean) => {
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
|
||||
}, []),
|
||||
|
||||
requestNavigation: useCallback((navigationFn: () => void) => {
|
||||
// If no unsaved changes, navigate immediately
|
||||
if (!state.hasUnsavedChanges) {
|
||||
navigationFn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, store the navigation and show warning
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
}, [state.hasUnsavedChanges]),
|
||||
|
||||
confirmNavigation: useCallback(() => {
|
||||
// Execute pending navigation
|
||||
if (state.pendingNavigation) {
|
||||
state.pendingNavigation();
|
||||
}
|
||||
|
||||
// Clear navigation state
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [state.pendingNavigation]),
|
||||
|
||||
cancelNavigation: useCallback(() => {
|
||||
// Clear navigation without executing
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [])
|
||||
};
|
||||
|
||||
const stateValue: NavigationContextStateValue = {
|
||||
currentMode: state.currentMode,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning
|
||||
};
|
||||
|
||||
const actionsValue: NavigationContextActionsValue = {
|
||||
actions
|
||||
};
|
||||
|
||||
// Enable URL synchronization
|
||||
useNavigationUrlSync(state.currentMode, actions.setMode, enableUrlSync);
|
||||
|
||||
return (
|
||||
<NavigationStateContext.Provider value={stateValue}>
|
||||
<NavigationActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
</NavigationActionsContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Navigation hooks
|
||||
export const useNavigationState = () => {
|
||||
const context = useContext(NavigationStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNavigationState must be used within NavigationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useNavigationActions = () => {
|
||||
const context = useContext(NavigationActionsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNavigationActions must be used within NavigationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Combined hook for convenience
|
||||
export const useNavigation = () => {
|
||||
const state = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
return { ...state, ...actions };
|
||||
};
|
||||
|
||||
// Navigation guard hook (equivalent to old useFileNavigation)
|
||||
export const useNavigationGuard = () => {
|
||||
const state = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
return {
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
requestNavigation: actions.requestNavigation,
|
||||
confirmNavigation: actions.confirmNavigation,
|
||||
cancelNavigation: actions.cancelNavigation,
|
||||
setHasUnsavedChanges: actions.setHasUnsavedChanges,
|
||||
setShowNavigationWarning: actions.showNavigationWarning
|
||||
};
|
||||
};
|
||||
|
||||
// Utility functions for mode handling
|
||||
export const isValidMode = (mode: string): mode is ModeType => {
|
||||
const validModes: ModeType[] = [
|
||||
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
|
||||
];
|
||||
return validModes.includes(mode as ModeType);
|
||||
};
|
||||
|
||||
export const getDefaultMode = (): ModeType => 'pageEditor';
|
||||
|
||||
// TODO: This will be expanded for URL-based routing system
|
||||
// - URL parsing utilities
|
||||
// - Route definitions
|
||||
// - Navigation hooks with URL sync
|
||||
// - History management
|
||||
// - Breadcrumb restoration from URL params
|
@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useRef, useMemo } from 'react';
|
||||
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||
@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||
const [readerMode, setReaderMode] = useState(false);
|
||||
|
||||
const sidebarState: SidebarState = {
|
||||
const sidebarState: SidebarState = useMemo(() => ({
|
||||
sidebarsVisible,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
};
|
||||
}), [sidebarsVisible, leftPanelView, readerMode]);
|
||||
|
||||
const sidebarRefs: SidebarRefs = {
|
||||
const sidebarRefs: SidebarRefs = useMemo(() => ({
|
||||
quickAccessRef,
|
||||
toolPanelRef,
|
||||
};
|
||||
}), [quickAccessRef, toolPanelRef]);
|
||||
|
||||
const contextValue: SidebarContextValue = {
|
||||
const contextValue: SidebarContextValue = useMemo(() => ({
|
||||
sidebarState,
|
||||
sidebarRefs,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
};
|
||||
}), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
|
@ -7,6 +7,7 @@ import React, { createContext, useContext, useReducer, useCallback, useMemo } fr
|
||||
import { useToolManagement } from '../hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '../types/pageEditor';
|
||||
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
|
||||
|
||||
// State interface
|
||||
interface ToolWorkflowState {
|
||||
@ -71,7 +72,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: any; // From useToolManagement
|
||||
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
|
||||
@ -101,9 +102,11 @@ interface ToolWorkflowProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Handler for view changes (passed from parent) */
|
||||
onViewChange?: (view: string) => void;
|
||||
/** Enable URL synchronization for tool selection */
|
||||
enableUrlSync?: boolean;
|
||||
}
|
||||
|
||||
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
|
||||
export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = true }: ToolWorkflowProviderProps) {
|
||||
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
||||
|
||||
// Tool management hook
|
||||
@ -182,6 +185,9 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
|
||||
[state.sidebarsVisible, state.readerMode]
|
||||
);
|
||||
|
||||
// Enable URL synchronization for tool selection
|
||||
useToolWorkflowUrlSync(selectedToolKey, selectTool, clearToolSelection, enableUrlSync);
|
||||
|
||||
// Simple context value with basic memoization
|
||||
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||
// State
|
||||
@ -224,4 +230,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
|
||||
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
240
frontend/src/contexts/file/FileReducer.ts
Normal file
240
frontend/src/contexts/file/FileReducer.ts
Normal file
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* FileContext reducer - Pure state management for file operations
|
||||
*/
|
||||
|
||||
import {
|
||||
FileContextState,
|
||||
FileContextAction,
|
||||
FileId,
|
||||
FileRecord
|
||||
} from '../../types/fileContext';
|
||||
|
||||
// Initial state
|
||||
export const initialFileContextState: FileContextState = {
|
||||
files: {
|
||||
ids: [],
|
||||
byId: {}
|
||||
},
|
||||
pinnedFiles: new Set(),
|
||||
ui: {
|
||||
selectedFileIds: [],
|
||||
selectedPageNumbers: [],
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
hasUnsavedChanges: false
|
||||
}
|
||||
};
|
||||
|
||||
// Pure reducer function
|
||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
case 'ADD_FILES': {
|
||||
const { fileRecords } = action.payload;
|
||||
const newIds: FileId[] = [];
|
||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||
|
||||
fileRecords.forEach(record => {
|
||||
// Only add if not already present (dedupe by stable ID)
|
||||
if (!newById[record.id]) {
|
||||
newIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...state.files.ids, ...newIds],
|
||||
byId: newById
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'REMOVE_FILES': {
|
||||
const { fileIds } = action.payload;
|
||||
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
|
||||
const newById = { ...state.files.byId };
|
||||
|
||||
// Remove files from state (resource cleanup handled by lifecycle manager)
|
||||
fileIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: remainingIds,
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'UPDATE_FILE_RECORD': {
|
||||
const { id, updates } = action.payload;
|
||||
const existingRecord = state.files.byId[id];
|
||||
|
||||
if (!existingRecord) {
|
||||
return state; // File doesn't exist, no-op
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
byId: {
|
||||
...state.files.byId,
|
||||
[id]: {
|
||||
...existingRecord,
|
||||
...updates
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'REORDER_FILES': {
|
||||
const { orderedFileIds } = action.payload;
|
||||
|
||||
// Validate that all IDs exist in current state
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
ids: validIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET_SELECTED_FILES': {
|
||||
const { fileIds } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: fileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET_SELECTED_PAGES': {
|
||||
const { pageNumbers } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedPageNumbers: pageNumbers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_SELECTIONS': {
|
||||
return {
|
||||
...state,
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: [],
|
||||
selectedPageNumbers: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET_PROCESSING': {
|
||||
const { isProcessing, progress } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: {
|
||||
...state.ui,
|
||||
isProcessing,
|
||||
processingProgress: progress
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET_UNSAVED_CHANGES': {
|
||||
return {
|
||||
...state,
|
||||
ui: {
|
||||
...state.ui,
|
||||
hasUnsavedChanges: action.payload.hasChanges
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'PIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
newPinnedFiles.add(fileId);
|
||||
|
||||
return {
|
||||
...state,
|
||||
pinnedFiles: newPinnedFiles
|
||||
};
|
||||
}
|
||||
|
||||
case 'UNPIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
newPinnedFiles.delete(fileId);
|
||||
|
||||
return {
|
||||
...state,
|
||||
pinnedFiles: newPinnedFiles
|
||||
};
|
||||
}
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputFileRecords } = action.payload;
|
||||
|
||||
// Only remove unpinned input files
|
||||
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
||||
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
||||
|
||||
// Remove unpinned files from state
|
||||
const newById = { ...state.files.byId };
|
||||
unpinnedInputIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Add output files
|
||||
const outputIds: FileId[] = [];
|
||||
outputFileRecords.forEach(record => {
|
||||
if (!newById[record.id]) {
|
||||
outputIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...remainingIds, ...outputIds],
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'RESET_CONTEXT': {
|
||||
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
||||
return { ...initialFileContextState };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
13
frontend/src/contexts/file/contexts.ts
Normal file
13
frontend/src/contexts/file/contexts.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* React contexts for file state and actions
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
import { FileContextStateValue, FileContextActionsValue } from '../../types/fileContext';
|
||||
|
||||
// Split contexts for performance
|
||||
export const FileStateContext = createContext<FileContextStateValue | undefined>(undefined);
|
||||
export const FileActionsContext = createContext<FileContextActionsValue | undefined>(undefined);
|
||||
|
||||
// Export types for use in hooks
|
||||
export type { FileContextStateValue, FileContextActionsValue };
|
370
frontend/src/contexts/file/fileActions.ts
Normal file
370
frontend/src/contexts/file/fileActions.ts
Normal file
@ -0,0 +1,370 @@
|
||||
/**
|
||||
* File actions - Unified file operations with single addFiles helper
|
||||
*/
|
||||
|
||||
import {
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
toFileRecord,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
} from '../../types/fileContext';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Simple mutex to prevent race conditions in addFiles
|
||||
*/
|
||||
class SimpleMutex {
|
||||
private locked = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
async lock(): Promise<void> {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(() => {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
unlock(): void {
|
||||
if (this.queue.length > 0) {
|
||||
const next = this.queue.shift()!;
|
||||
next();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global mutex for addFiles operations
|
||||
const addFilesMutex = new SimpleMutex();
|
||||
|
||||
/**
|
||||
* Helper to create ProcessedFile metadata structure
|
||||
*/
|
||||
export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
||||
return {
|
||||
totalPages: pageCount,
|
||||
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||
pageNumber: index + 1,
|
||||
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
|
||||
rotation: 0,
|
||||
splitBefore: false
|
||||
})),
|
||||
thumbnailUrl: thumbnail,
|
||||
lastProcessed: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* File addition types
|
||||
*/
|
||||
type AddFileKind = 'raw' | 'processed' | 'stored';
|
||||
|
||||
interface AddFileOptions {
|
||||
// For 'raw' files
|
||||
files?: File[];
|
||||
|
||||
// For 'processed' files
|
||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||
|
||||
// For 'stored' files
|
||||
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||
*/
|
||||
export async function addFiles(
|
||||
kind: AddFileKind,
|
||||
options: AddFileOptions,
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager
|
||||
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const fileRecords: FileRecord[] = [];
|
||||
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||
|
||||
switch (kind) {
|
||||
case 'raw': {
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
for (const file of files) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count immediately
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
// Non-PDF files: simple thumbnail generation, no page count
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create record with immediate thumbnail and page metadata
|
||||
const record = toFileRecord(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial processedFile metadata with page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'processed': {
|
||||
const { filesWithThumbnails = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||
|
||||
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Create processedFile with provided metadata
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stored': {
|
||||
const { filesWithMetadata = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||
|
||||
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
// Try to preserve original ID, but generate new if it conflicts
|
||||
let fileId = originalId;
|
||||
if (filesRef.current.has(originalId)) {
|
||||
fileId = createFileId();
|
||||
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||
}
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||
|
||||
// Get page count from PDF
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
pageCount = pdf.numPages;
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||
}
|
||||
|
||||
// Restore metadata from storage
|
||||
if (metadata.thumbnail) {
|
||||
record.thumbnailUrl = metadata.thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (metadata.thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Create processedFile metadata with correct page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (fileRecords.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
} finally {
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
|
||||
// Process output files through the 'processed' path to generate thumbnails
|
||||
const outputFileRecords = await Promise.all(
|
||||
outputFiles.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count for output file
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
}
|
||||
|
||||
return record;
|
||||
})
|
||||
);
|
||||
|
||||
// Dispatch the consume action
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputFileRecords
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action factory functions
|
||||
*/
|
||||
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
|
||||
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
||||
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
||||
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
||||
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
||||
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
||||
});
|
193
frontend/src/contexts/file/fileHooks.ts
Normal file
193
frontend/src/contexts/file/fileHooks.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Performant file hooks - Clean API using FileContext
|
||||
*/
|
||||
|
||||
import { useContext, useMemo } from 'react';
|
||||
import {
|
||||
FileStateContext,
|
||||
FileActionsContext,
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue
|
||||
} from './contexts';
|
||||
import { FileId, FileRecord } from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Hook for accessing file state (will re-render on any state change)
|
||||
* Use individual selector hooks below for better performance
|
||||
*/
|
||||
export function useFileState(): FileContextStateValue {
|
||||
const context = useContext(FileStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileState must be used within a FileContextProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing file actions (stable - won't cause re-renders)
|
||||
*/
|
||||
export function useFileActions(): FileContextActionsValue {
|
||||
const context = useContext(FileActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileActions must be used within a FileContextProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for current/primary file (first in list)
|
||||
*/
|
||||
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
const primaryFileId = state.files.ids[0];
|
||||
return useMemo(() => ({
|
||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
||||
}), [primaryFileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for file selection state and actions
|
||||
*/
|
||||
export function useFileSelection() {
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Memoize selected files to avoid recreating arrays
|
||||
const selectedFiles = useMemo(() => {
|
||||
return selectors.getSelectedFiles();
|
||||
}, [state.ui.selectedFileIds, selectors]);
|
||||
|
||||
return useMemo(() => ({
|
||||
selectedFiles,
|
||||
selectedFileIds: state.ui.selectedFileIds,
|
||||
selectedPageNumbers: state.ui.selectedPageNumbers,
|
||||
setSelectedFiles: actions.setSelectedFiles,
|
||||
setSelectedPages: actions.setSelectedPages,
|
||||
clearSelections: actions.clearSelections
|
||||
}), [
|
||||
selectedFiles,
|
||||
state.ui.selectedFileIds,
|
||||
state.ui.selectedPageNumbers,
|
||||
actions.setSelectedFiles,
|
||||
actions.setSelectedPages,
|
||||
actions.clearSelections
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for file management operations
|
||||
*/
|
||||
export function useFileManagement() {
|
||||
const { actions } = useFileActions();
|
||||
|
||||
return useMemo(() => ({
|
||||
addFiles: actions.addFiles,
|
||||
removeFiles: actions.removeFiles,
|
||||
clearAllFiles: actions.clearAllFiles,
|
||||
updateFileRecord: actions.updateFileRecord,
|
||||
reorderFiles: actions.reorderFiles
|
||||
}), [actions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for UI state
|
||||
*/
|
||||
export function useFileUI() {
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
return useMemo(() => ({
|
||||
isProcessing: state.ui.isProcessing,
|
||||
processingProgress: state.ui.processingProgress,
|
||||
hasUnsavedChanges: state.ui.hasUnsavedChanges,
|
||||
setProcessing: actions.setProcessing,
|
||||
setUnsavedChanges: actions.setHasUnsavedChanges
|
||||
}), [state.ui, actions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for specific file by ID (optimized for individual file access)
|
||||
*/
|
||||
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
||||
const { selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
file: selectors.getFile(fileId),
|
||||
record: selectors.getFileRecord(fileId)
|
||||
}), [fileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getFiles(),
|
||||
records: selectors.getFileRecords(),
|
||||
fileIds: state.files.ids
|
||||
}), [state.files.ids, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getSelectedFiles(),
|
||||
records: selectors.getSelectedFileRecords(),
|
||||
fileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
|
||||
// Navigation management removed - moved to NavigationContext
|
||||
|
||||
/**
|
||||
* Primary API hook for file context operations
|
||||
* Used by tools for core file context functionality
|
||||
*/
|
||||
export function useFileContext() {
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
return useMemo(() => ({
|
||||
// Lifecycle management
|
||||
trackBlobUrl: actions.trackBlobUrl,
|
||||
scheduleCleanup: actions.scheduleCleanup,
|
||||
setUnsavedChanges: actions.setHasUnsavedChanges,
|
||||
|
||||
// File management
|
||||
addFiles: actions.addFiles,
|
||||
consumeFiles: actions.consumeFiles,
|
||||
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||
|
||||
// File ID lookup
|
||||
findFileId: (file: File) => {
|
||||
return state.files.ids.find(id => {
|
||||
const record = state.files.byId[id];
|
||||
return record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified;
|
||||
});
|
||||
},
|
||||
|
||||
// Pinned files
|
||||
pinnedFiles: state.pinnedFiles,
|
||||
pinFile: actions.pinFile,
|
||||
unpinFile: actions.unpinFile,
|
||||
isFilePinned: selectors.isFilePinned,
|
||||
|
||||
// Active files
|
||||
activeFiles: selectors.getFiles()
|
||||
}), [state, selectors, actions]);
|
||||
}
|
||||
|
||||
|
130
frontend/src/contexts/file/fileSelectors.ts
Normal file
130
frontend/src/contexts/file/fileSelectors.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* File selectors - Pure functions for accessing file state
|
||||
*/
|
||||
|
||||
import {
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileContextState,
|
||||
FileContextSelectors
|
||||
} from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Create stable selectors using stateRef and filesRef
|
||||
*/
|
||||
export function createFileSelectors(
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): FileContextSelectors {
|
||||
return {
|
||||
getFile: (id: FileId) => filesRef.current.get(id),
|
||||
|
||||
getFiles: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
||||
},
|
||||
|
||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||
|
||||
getFileRecords: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||
},
|
||||
|
||||
getAllFileIds: () => stateRef.current.files.ids,
|
||||
|
||||
getSelectedFiles: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
},
|
||||
|
||||
getSelectedFileRecords: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
// Pinned files selectors
|
||||
getPinnedFileIds: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles);
|
||||
},
|
||||
|
||||
getPinnedFiles: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
},
|
||||
|
||||
getPinnedFileRecords: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
isFilePinned: (file: File) => {
|
||||
// Find FileId by matching File object properties
|
||||
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
|
||||
const storedFile = filesRef.current.get(id);
|
||||
return storedFile &&
|
||||
storedFile.name === file.name &&
|
||||
storedFile.size === file.size &&
|
||||
storedFile.lastModified === file.lastModified;
|
||||
});
|
||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
||||
},
|
||||
|
||||
// Stable signature for effects - prevents unnecessary re-renders
|
||||
getFilesSignature: () => {
|
||||
return stateRef.current.files.ids
|
||||
.map(id => {
|
||||
const record = stateRef.current.files.byId[id];
|
||||
return record ? `${id}:${record.size}:${record.lastModified}` : '';
|
||||
})
|
||||
.join('|');
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for building quickKey sets for deduplication
|
||||
*/
|
||||
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
Object.values(fileRecords).forEach(record => {
|
||||
if (record.quickKey) {
|
||||
quickKeys.add(record.quickKey);
|
||||
}
|
||||
});
|
||||
return quickKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for building quickKey sets from IndexedDB metadata
|
||||
*/
|
||||
export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; size: number; lastModified: number }>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
metadata.forEach(meta => {
|
||||
// Format: name|size|lastModified (same as createQuickKey)
|
||||
const quickKey = `${meta.name}|${meta.size}|${meta.lastModified}`;
|
||||
quickKeys.add(quickKey);
|
||||
});
|
||||
return quickKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get primary file (first in list) - commonly used pattern
|
||||
*/
|
||||
export function getPrimaryFile(
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): { file?: File; record?: FileRecord } {
|
||||
const primaryFileId = stateRef.current.files.ids[0];
|
||||
if (!primaryFileId) return {};
|
||||
|
||||
return {
|
||||
file: filesRef.current.get(primaryFileId),
|
||||
record: stateRef.current.files.byId[primaryFileId]
|
||||
};
|
||||
}
|
190
frontend/src/contexts/file/lifecycle.ts
Normal file
190
frontend/src/contexts/file/lifecycle.ts
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* File lifecycle management - Resource cleanup and memory management
|
||||
*/
|
||||
|
||||
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Resource tracking and cleanup utilities
|
||||
*/
|
||||
export class FileLifecycleManager {
|
||||
private cleanupTimers = new Map<string, number>();
|
||||
private blobUrls = new Set<string>();
|
||||
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
|
||||
|
||||
constructor(
|
||||
private filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
private dispatch: React.Dispatch<FileContextAction>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Track blob URLs for cleanup
|
||||
*/
|
||||
trackBlobUrl = (url: string): void => {
|
||||
// Only track actual blob URLs to avoid trying to revoke other schemes
|
||||
if (url.startsWith('blob:')) {
|
||||
this.blobUrls.add(url);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clean up resources for a specific file (with stateRef access for complete cleanup)
|
||||
*/
|
||||
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Use comprehensive cleanup (same as removeFiles)
|
||||
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||
|
||||
// Remove file from state
|
||||
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up all files and resources
|
||||
*/
|
||||
cleanupAllFiles = (): void => {
|
||||
// Revoke all blob URLs
|
||||
this.blobUrls.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
});
|
||||
this.blobUrls.clear();
|
||||
|
||||
// Clear all cleanup timers and generations
|
||||
this.cleanupTimers.forEach(timer => clearTimeout(timer));
|
||||
this.cleanupTimers.clear();
|
||||
this.fileGenerations.clear();
|
||||
|
||||
// Clear files ref
|
||||
this.filesRef.current.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
|
||||
*/
|
||||
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Cancel existing timer
|
||||
const existingTimer = this.cleanupTimers.get(fileId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.cleanupTimers.delete(fileId);
|
||||
}
|
||||
|
||||
// If delay is negative, just cancel (don't reschedule)
|
||||
if (delay < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment generation for this file to invalidate any pending cleanup
|
||||
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
|
||||
this.fileGenerations.set(fileId, currentGen);
|
||||
|
||||
// Schedule new cleanup with generation token
|
||||
const timer = window.setTimeout(() => {
|
||||
// Check if this cleanup is still valid (file hasn't been re-added)
|
||||
if (this.fileGenerations.get(fileId) === currentGen) {
|
||||
this.cleanupFile(fileId, stateRef);
|
||||
} else {
|
||||
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
this.cleanupTimers.set(fileId, timer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a file immediately with complete resource cleanup
|
||||
*/
|
||||
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
|
||||
fileIds.forEach(fileId => {
|
||||
// Clean up all resources for this file
|
||||
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||
});
|
||||
|
||||
// Dispatch removal action once for all files (reducer only updates state)
|
||||
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete resource cleanup for a single file
|
||||
*/
|
||||
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Remove from files ref
|
||||
this.filesRef.current.delete(fileId);
|
||||
|
||||
// Cancel cleanup timer and generation
|
||||
const timer = this.cleanupTimers.get(fileId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.cleanupTimers.delete(fileId);
|
||||
}
|
||||
this.fileGenerations.delete(fileId);
|
||||
|
||||
// Clean up blob URLs from file record if we have access to state
|
||||
if (stateRef) {
|
||||
const record = stateRef.current.files.byId[fileId];
|
||||
if (record) {
|
||||
// Clean up thumbnail blob URLs
|
||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.thumbnailUrl);
|
||||
} catch (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.blobUrl);
|
||||
} catch (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up processed file thumbnails
|
||||
if (record.processedFile?.pages) {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
|
||||
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(page.thumbnail);
|
||||
} catch (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update file record with race condition guards
|
||||
*/
|
||||
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Guard against updating removed files (race condition protection)
|
||||
if (!this.filesRef.current.has(fileId)) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional state guard for rare race conditions
|
||||
if (stateRef && !stateRef.current.files.byId[fileId]) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup on unmount
|
||||
*/
|
||||
destroy = (): void => {
|
||||
this.cleanupAllFiles();
|
||||
};
|
||||
}
|
@ -1,102 +1,103 @@
|
||||
import { type TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
import { BaseToolProps } from '../types/tool';
|
||||
|
||||
export enum SubcategoryId {
|
||||
SIGNING = 'signing',
|
||||
DOCUMENT_SECURITY = 'documentSecurity',
|
||||
VERIFICATION = 'verification',
|
||||
DOCUMENT_REVIEW = 'documentReview',
|
||||
PAGE_FORMATTING = 'pageFormatting',
|
||||
EXTRACTION = 'extraction',
|
||||
REMOVAL = 'removal',
|
||||
AUTOMATION = 'automation',
|
||||
GENERAL = 'general',
|
||||
ADVANCED_FORMATTING = 'advancedFormatting',
|
||||
DEVELOPER_TOOLS = 'developerTools'
|
||||
SIGNING = 'signing',
|
||||
DOCUMENT_SECURITY = 'documentSecurity',
|
||||
VERIFICATION = 'verification',
|
||||
DOCUMENT_REVIEW = 'documentReview',
|
||||
PAGE_FORMATTING = 'pageFormatting',
|
||||
EXTRACTION = 'extraction',
|
||||
REMOVAL = 'removal',
|
||||
AUTOMATION = 'automation',
|
||||
GENERAL = 'general',
|
||||
ADVANCED_FORMATTING = 'advancedFormatting',
|
||||
DEVELOPER_TOOLS = 'developerTools'
|
||||
}
|
||||
|
||||
export enum ToolCategory {
|
||||
STANDARD_TOOLS = 'Standard Tools',
|
||||
ADVANCED_TOOLS = 'Advanced Tools',
|
||||
RECOMMENDED_TOOLS = 'Recommended Tools'
|
||||
export enum ToolCategoryId {
|
||||
STANDARD_TOOLS = 'standardTools',
|
||||
ADVANCED_TOOLS = 'advancedTools',
|
||||
RECOMMENDED_TOOLS = 'recommendedTools'
|
||||
}
|
||||
|
||||
export type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<any> | null;
|
||||
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
|
||||
description: string;
|
||||
category: ToolCategory;
|
||||
subcategory: SubcategoryId;
|
||||
maxFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
endpoints?: string[];
|
||||
link?: string;
|
||||
type?: string;
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<BaseToolProps> | null;
|
||||
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
|
||||
description: string;
|
||||
categoryId: ToolCategoryId;
|
||||
subcategoryId: SubcategoryId;
|
||||
maxFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
endpoints?: string[];
|
||||
link?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export type ToolRegistry = Record<string, ToolRegistryEntry>;
|
||||
export type ToolRegistry = Record<string /* FIX ME: Should be ToolId */, ToolRegistryEntry>;
|
||||
|
||||
export const SUBCATEGORY_ORDER: SubcategoryId[] = [
|
||||
SubcategoryId.SIGNING,
|
||||
SubcategoryId.DOCUMENT_SECURITY,
|
||||
SubcategoryId.VERIFICATION,
|
||||
SubcategoryId.DOCUMENT_REVIEW,
|
||||
SubcategoryId.PAGE_FORMATTING,
|
||||
SubcategoryId.EXTRACTION,
|
||||
SubcategoryId.REMOVAL,
|
||||
SubcategoryId.AUTOMATION,
|
||||
SubcategoryId.GENERAL,
|
||||
SubcategoryId.ADVANCED_FORMATTING,
|
||||
SubcategoryId.DEVELOPER_TOOLS,
|
||||
SubcategoryId.SIGNING,
|
||||
SubcategoryId.DOCUMENT_SECURITY,
|
||||
SubcategoryId.VERIFICATION,
|
||||
SubcategoryId.DOCUMENT_REVIEW,
|
||||
SubcategoryId.PAGE_FORMATTING,
|
||||
SubcategoryId.EXTRACTION,
|
||||
SubcategoryId.REMOVAL,
|
||||
SubcategoryId.AUTOMATION,
|
||||
SubcategoryId.GENERAL,
|
||||
SubcategoryId.ADVANCED_FORMATTING,
|
||||
SubcategoryId.DEVELOPER_TOOLS,
|
||||
];
|
||||
|
||||
export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
|
||||
[SubcategoryId.SIGNING]: '#FF7892',
|
||||
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
|
||||
[SubcategoryId.VERIFICATION]: '#1BB1D4',
|
||||
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
|
||||
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
|
||||
[SubcategoryId.EXTRACTION]: '#1BB1D4',
|
||||
[SubcategoryId.REMOVAL]: '#7882FF',
|
||||
[SubcategoryId.AUTOMATION]: '#69DC95',
|
||||
[SubcategoryId.GENERAL]: '#69DC95',
|
||||
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
|
||||
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
|
||||
[SubcategoryId.SIGNING]: '#FF7892',
|
||||
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
|
||||
[SubcategoryId.VERIFICATION]: '#1BB1D4',
|
||||
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
|
||||
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
|
||||
[SubcategoryId.EXTRACTION]: '#1BB1D4',
|
||||
[SubcategoryId.REMOVAL]: '#7882FF',
|
||||
[SubcategoryId.AUTOMATION]: '#69DC95',
|
||||
[SubcategoryId.GENERAL]: '#69DC95',
|
||||
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
|
||||
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
|
||||
};
|
||||
|
||||
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
|
||||
|
||||
export const getCategoryLabel = (t: TFunction, id: ToolCategoryId): string => t(`toolPicker.categories.${id}`, id);
|
||||
export const getSubcategoryLabel = (t: TFunction, id: SubcategoryId): string => t(`toolPicker.subcategories.${id}`, id);
|
||||
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
|
||||
|
||||
|
||||
|
||||
export const getAllEndpoints = (registry: ToolRegistry): string[] => {
|
||||
const lists: string[][] = [];
|
||||
Object.values(registry).forEach(entry => {
|
||||
if (entry.endpoints && entry.endpoints.length > 0) {
|
||||
lists.push(entry.endpoints);
|
||||
}
|
||||
});
|
||||
return Array.from(new Set(lists.flat()));
|
||||
const lists: string[][] = [];
|
||||
Object.values(registry).forEach(entry => {
|
||||
if (entry.endpoints && entry.endpoints.length > 0) {
|
||||
lists.push(entry.endpoints);
|
||||
}
|
||||
});
|
||||
return Array.from(new Set(lists.flat()));
|
||||
};
|
||||
|
||||
export const getConversionEndpoints = (extensionToEndpoint: Record<string, Record<string, string>>): string[] => {
|
||||
const endpoints = new Set<string>();
|
||||
Object.values(extensionToEndpoint).forEach(toEndpoints => {
|
||||
Object.values(toEndpoints).forEach(endpoint => {
|
||||
endpoints.add(endpoint);
|
||||
});
|
||||
});
|
||||
return Array.from(endpoints);
|
||||
const endpoints = new Set<string>();
|
||||
Object.values(extensionToEndpoint).forEach(toEndpoints => {
|
||||
Object.values(toEndpoints).forEach(endpoint => {
|
||||
endpoints.add(endpoint);
|
||||
});
|
||||
});
|
||||
return Array.from(endpoints);
|
||||
};
|
||||
|
||||
export const getAllApplicationEndpoints = (
|
||||
registry: ToolRegistry,
|
||||
extensionToEndpoint?: Record<string, Record<string, string>>
|
||||
registry: ToolRegistry,
|
||||
extensionToEndpoint?: Record<string, Record<string, string>>
|
||||
): string[] => {
|
||||
const toolEp = getAllEndpoints(registry);
|
||||
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
|
||||
return Array.from(new Set([...toolEp, ...convEp]));
|
||||
const toolEp = getAllEndpoints(registry);
|
||||
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
|
||||
return Array.from(new Set([...toolEp, ...convEp]));
|
||||
};
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
@ -8,14 +7,20 @@ import Sanitize from '../tools/Sanitize';
|
||||
import AddPassword from '../tools/AddPassword';
|
||||
import ChangePermissions from '../tools/ChangePermissions';
|
||||
import RemovePassword from '../tools/RemovePassword';
|
||||
import { SubcategoryId, ToolCategory, ToolRegistry } from './toolsTaxonomy';
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from './toolsTaxonomy';
|
||||
import AddWatermark from '../tools/AddWatermark';
|
||||
import Repair from '../tools/Repair';
|
||||
import SingleLargePage from '../tools/SingleLargePage';
|
||||
import UnlockPdfForms from '../tools/UnlockPdfForms';
|
||||
import RemoveCertificateSign from '../tools/RemoveCertificateSign';
|
||||
|
||||
const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented
|
||||
|
||||
// Hook to get the translated tool registry
|
||||
export function useFlatToolRegistry(): ToolRegistry {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allTools: ToolRegistry = {
|
||||
// Signing
|
||||
|
||||
"certSign": {
|
||||
@ -24,8 +29,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "sign",
|
||||
description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.SIGNING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING
|
||||
},
|
||||
"sign": {
|
||||
icon: <span className="material-symbols-rounded">signature</span>,
|
||||
@ -33,8 +38,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "sign",
|
||||
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.SIGNING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING
|
||||
},
|
||||
|
||||
|
||||
@ -46,20 +51,20 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: AddPassword,
|
||||
view: "security",
|
||||
description: t("home.addPassword.desc", "Add password protection and restrictions to PDF files"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"]
|
||||
},
|
||||
"add-watermark": {
|
||||
},
|
||||
"watermark": {
|
||||
icon: <span className="material-symbols-rounded">branding_watermark</span>,
|
||||
name: t("home.watermark.title", "Add Watermark"),
|
||||
component: AddWatermark,
|
||||
view: "format",
|
||||
maxFiles: -1,
|
||||
description: t("home.watermark.desc", "Add a custom watermark to your PDF document."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
endpoints: ["add-watermark"]
|
||||
},
|
||||
"add-stamp": {
|
||||
@ -68,8 +73,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
||||
},
|
||||
"sanitize": {
|
||||
icon: <span className="material-symbols-rounded">cleaning_services</span>,
|
||||
@ -77,8 +82,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: Sanitize,
|
||||
view: "security",
|
||||
maxFiles: -1,
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
|
||||
endpoints: ["sanitize-pdf"]
|
||||
},
|
||||
@ -88,17 +93,19 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
||||
},
|
||||
"unlock-pdf-forms": {
|
||||
icon: <span className="material-symbols-rounded">preview_off</span>,
|
||||
name: t("home.unlockPDFForms.title", "Unlock PDF Forms"),
|
||||
component: null,
|
||||
component: UnlockPdfForms,
|
||||
view: "security",
|
||||
description: t("home.unlockPDFForms.desc", "Remove read-only property of form fields in a PDF document."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["unlock-pdf-forms"]
|
||||
},
|
||||
"manage-certificates": {
|
||||
icon: <span className="material-symbols-rounded">license</span>,
|
||||
@ -106,8 +113,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "security",
|
||||
description: t("home.manageCertificates.desc", "Import, export, or delete digital certificate files used for signing PDFs."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
||||
},
|
||||
"change-permissions": {
|
||||
icon: <span className="material-symbols-rounded">lock</span>,
|
||||
@ -115,8 +122,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: ChangePermissions,
|
||||
view: "security",
|
||||
description: t("home.changePermissions.desc", "Change document restrictions and permissions"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_SECURITY,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"]
|
||||
},
|
||||
@ -128,8 +135,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "extract",
|
||||
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.VERIFICATION
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION
|
||||
},
|
||||
"validate-pdf-signature": {
|
||||
icon: <span className="material-symbols-rounded">verified</span>,
|
||||
@ -137,11 +144,11 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "security",
|
||||
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.VERIFICATION
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
// Document Review
|
||||
|
||||
"read": {
|
||||
@ -150,8 +157,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "view",
|
||||
description: t("home.read.desc", "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_REVIEW
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
|
||||
},
|
||||
"change-metadata": {
|
||||
icon: <span className="material-symbols-rounded">assignment</span>,
|
||||
@ -159,8 +166,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.DOCUMENT_REVIEW
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
|
||||
},
|
||||
// Page Formatting
|
||||
|
||||
@ -170,8 +177,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"rotate": {
|
||||
icon: <span className="material-symbols-rounded">rotate_right</span>,
|
||||
@ -179,8 +186,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.rotate.desc", "Easily rotate your PDFs."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"splitPdf": {
|
||||
icon: <span className="material-symbols-rounded">content_cut</span>,
|
||||
@ -188,8 +195,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: SplitPdfPanel,
|
||||
view: "split",
|
||||
description: t("home.split.desc", "Split PDFs into multiple documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"reorganize-pages": {
|
||||
icon: <span className="material-symbols-rounded">move_down</span>,
|
||||
@ -197,8 +204,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "pageEditor",
|
||||
description: t("home.reorganizePages.desc", "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"adjust-page-size-scale": {
|
||||
icon: <span className="material-symbols-rounded">crop_free</span>,
|
||||
@ -206,17 +213,17 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"add-page-numbers": {
|
||||
"addPageNumbers": {
|
||||
icon: <span className="material-symbols-rounded">123</span>,
|
||||
name: t("home.add-page-numbers.title", "Add Page Numbers"),
|
||||
name: t("home.addPageNumbers.title", "Add Page Numbers"),
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.add-page-numbers.desc", "Add Page numbers throughout a document in a set location"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"multi-page-layout": {
|
||||
icon: <span className="material-symbols-rounded">dashboard</span>,
|
||||
@ -224,17 +231,19 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
},
|
||||
"single-large-page": {
|
||||
icon: <span className="material-symbols-rounded">looks_one</span>,
|
||||
name: t("home.PdfToSinglePage.title", "PDF to Single Large Page"),
|
||||
component: null,
|
||||
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
|
||||
component: SingleLargePage,
|
||||
view: "format",
|
||||
description: t("home.PdfToSinglePage.desc", "Merges all PDF pages into one large single page"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING
|
||||
description: t("home.pdfToSinglePage.desc", "Merges all PDF pages into one large single page"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["pdf-to-single-page"]
|
||||
},
|
||||
"add-attachments": {
|
||||
icon: <span className="material-symbols-rounded">attachment</span>,
|
||||
@ -242,21 +251,21 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.attachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.PAGE_FORMATTING,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
},
|
||||
|
||||
|
||||
// Extraction
|
||||
|
||||
"extract-pages": {
|
||||
"extractPages": {
|
||||
icon: <span className="material-symbols-rounded">upload</span>,
|
||||
name: t("home.extractPage.title", "Extract Pages"),
|
||||
name: t("home.extractPages.title", "Extract Pages"),
|
||||
component: null,
|
||||
view: "extract",
|
||||
description: t("home.extractPage.desc", "Extract specific pages from a PDF document"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.EXTRACTION
|
||||
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION
|
||||
},
|
||||
"extract-images": {
|
||||
icon: <span className="material-symbols-rounded">filter</span>,
|
||||
@ -264,21 +273,21 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "extract",
|
||||
description: t("home.extractImages.desc", "Extract images from PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.EXTRACTION
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION
|
||||
},
|
||||
|
||||
|
||||
// Removal
|
||||
|
||||
"remove": {
|
||||
"removePages": {
|
||||
icon: <span className="material-symbols-rounded">delete</span>,
|
||||
name: t("home.removePages.title", "Remove Pages"),
|
||||
component: null,
|
||||
view: "remove",
|
||||
description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.REMOVAL
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL
|
||||
},
|
||||
"remove-blank-pages": {
|
||||
icon: <span className="material-symbols-rounded">scan_delete</span>,
|
||||
@ -286,8 +295,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "remove",
|
||||
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.REMOVAL
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL
|
||||
},
|
||||
"remove-annotations": {
|
||||
icon: <span className="material-symbols-rounded">thread_unread</span>,
|
||||
@ -295,8 +304,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "remove",
|
||||
description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.REMOVAL
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL
|
||||
},
|
||||
"remove-image": {
|
||||
icon: <span className="material-symbols-rounded">remove_selection</span>,
|
||||
@ -304,8 +313,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.removeImagePdf.desc", "Remove images from PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.REMOVAL
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL
|
||||
},
|
||||
"remove-password": {
|
||||
icon: <span className="material-symbols-rounded">lock_open_right</span>,
|
||||
@ -313,20 +322,22 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: RemovePassword,
|
||||
view: "security",
|
||||
description: t("home.removePassword.desc", "Remove password protection from PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.REMOVAL,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
endpoints: ["remove-password"],
|
||||
maxFiles: -1,
|
||||
|
||||
},
|
||||
"remove-certificate-sign": {
|
||||
icon: <span className="material-symbols-rounded">remove_moderator</span>,
|
||||
name: t("home.removeCertSign.title", "Remove Certificate Signatures"),
|
||||
component: null,
|
||||
name: t("home.removeCertSign.title", "Remove Certificate Sign"),
|
||||
component: RemoveCertificateSign,
|
||||
view: "security",
|
||||
description: t("home.removeCertSign.desc", "Remove digital signatures from PDF documents"),
|
||||
category: ToolCategory.STANDARD_TOOLS,
|
||||
subcategory: SubcategoryId.REMOVAL
|
||||
description: t("home.removeCertSign.desc", "Remove digital signature from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"]
|
||||
},
|
||||
|
||||
|
||||
@ -338,8 +349,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.AUTOMATION
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION
|
||||
},
|
||||
"auto-rename-pdf-file": {
|
||||
icon: <span className="material-symbols-rounded">match_word</span>,
|
||||
@ -347,8 +358,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.AUTOMATION
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION
|
||||
},
|
||||
"auto-split-pages": {
|
||||
icon: <span className="material-symbols-rounded">split_scene_right</span>,
|
||||
@ -356,8 +367,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.AUTOMATION
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION
|
||||
},
|
||||
"auto-split-by-size-count": {
|
||||
icon: <span className="material-symbols-rounded">content_cut</span>,
|
||||
@ -365,30 +376,32 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.AUTOMATION
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION
|
||||
},
|
||||
|
||||
|
||||
// Advanced Formatting
|
||||
|
||||
"adjust-colors-contrast": {
|
||||
"adjustContrast": {
|
||||
icon: <span className="material-symbols-rounded">palette</span>,
|
||||
name: t("home.adjust-contrast.title", "Adjust Colors/Contrast"),
|
||||
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.adjust-contrast.desc", "Adjust colors and contrast of PDF documents"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
"repair": {
|
||||
icon: <span className="material-symbols-rounded">build</span>,
|
||||
name: t("home.repair.title", "Repair"),
|
||||
component: null,
|
||||
component: Repair,
|
||||
view: "format",
|
||||
description: t("home.repair.desc", "Repair corrupted or damaged PDF files"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["repair"]
|
||||
},
|
||||
"detect-split-scanned-photos": {
|
||||
icon: <span className="material-symbols-rounded">scanner</span>,
|
||||
@ -396,8 +409,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.ScannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
"overlay-pdfs": {
|
||||
icon: <span className="material-symbols-rounded">layers</span>,
|
||||
@ -405,8 +418,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
"replace-and-invert-color": {
|
||||
icon: <span className="material-symbols-rounded">format_color_fill</span>,
|
||||
@ -414,8 +427,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
"add-image": {
|
||||
icon: <span className="material-symbols-rounded">image</span>,
|
||||
@ -423,8 +436,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.addImage.desc", "Add images to PDF documents"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
"edit-table-of-contents": {
|
||||
icon: <span className="material-symbols-rounded">bookmark_add</span>,
|
||||
@ -432,8 +445,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
"scanner-effect": {
|
||||
icon: <span className="material-symbols-rounded">scanner</span>,
|
||||
@ -441,8 +454,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.ADVANCED_FORMATTING
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||
},
|
||||
|
||||
|
||||
@ -454,8 +467,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "extract",
|
||||
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.DEVELOPER_TOOLS
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS
|
||||
},
|
||||
"dev-api": {
|
||||
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
|
||||
@ -463,8 +476,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "external",
|
||||
description: t("home.devApi.desc", "Link to API documentation"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.DEVELOPER_TOOLS,
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html"
|
||||
},
|
||||
"dev-folder-scanning": {
|
||||
@ -473,8 +486,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "external",
|
||||
description: t("home.devFolderScanning.desc", "Link to automated folder scanning guide"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.DEVELOPER_TOOLS,
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/"
|
||||
},
|
||||
"dev-sso-guide": {
|
||||
@ -483,8 +496,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "external",
|
||||
description: t("home.devSsoGuide.desc", "Link to SSO guide"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.DEVELOPER_TOOLS,
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
|
||||
},
|
||||
"dev-airgapped": {
|
||||
@ -493,8 +506,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "external",
|
||||
description: t("home.devAirgapped.desc", "Link to air-gapped setup guide"),
|
||||
category: ToolCategory.ADVANCED_TOOLS,
|
||||
subcategory: SubcategoryId.DEVELOPER_TOOLS,
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Pro/#activation"
|
||||
},
|
||||
|
||||
@ -506,27 +519,27 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "format",
|
||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL
|
||||
},
|
||||
"compressPdfs": {
|
||||
"compress": {
|
||||
icon: <span className="material-symbols-rounded">zoom_in_map</span>,
|
||||
name: t("home.compressPdfs.title", "Compress"),
|
||||
name: t("home.compress.title", "Compress"),
|
||||
component: CompressPdfPanel,
|
||||
view: "compress",
|
||||
description: t("home.compressPdfs.desc", "Compress PDFs to reduce their file size."),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL,
|
||||
description: t("home.compress.desc", "Compress PDFs to reduce their file size."),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1
|
||||
},
|
||||
"convert": {
|
||||
icon: <span className="material-symbols-rounded">sync_alt</span>,
|
||||
name: t("home.fileToPDF.title", "Convert"),
|
||||
name: t("home.convert.title", "Convert"),
|
||||
component: ConvertPanel,
|
||||
view: "convert",
|
||||
description: t("home.fileToPDF.desc", "Convert files to and from PDF format"),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL,
|
||||
description: t("home.convert.desc", "Convert files to and from PDF format"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: [
|
||||
"pdf-to-img",
|
||||
@ -569,8 +582,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "merge",
|
||||
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL,
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1
|
||||
},
|
||||
"multi-tool": {
|
||||
@ -579,8 +592,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "pageEditor",
|
||||
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL,
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1
|
||||
},
|
||||
"ocr": {
|
||||
@ -589,8 +602,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: OCRPanel,
|
||||
view: "convert",
|
||||
description: t("home.ocr.desc", "Extract text from scanned PDFs using Optical Character Recognition"),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL,
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1
|
||||
},
|
||||
"redact": {
|
||||
@ -599,8 +612,20 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
component: null,
|
||||
view: "redact",
|
||||
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
|
||||
category: ToolCategory.RECOMMENDED_TOOLS,
|
||||
subcategory: SubcategoryId.GENERAL
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (showPlaceholderTools) {
|
||||
return allTools;
|
||||
} else {
|
||||
const filteredTools = Object.keys(allTools)
|
||||
.filter(key => allTools[key].component !== null || allTools[key].link)
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = allTools[key];
|
||||
return obj;
|
||||
}, {} as ToolRegistry);
|
||||
return filteredTools;
|
||||
}
|
||||
}
|
||||
|
1
frontend/src/global.d.ts
vendored
1
frontend/src/global.d.ts
vendored
@ -1,6 +1,5 @@
|
||||
declare module "../tools/Split";
|
||||
declare module "../tools/Compress";
|
||||
declare module "../tools/Merge";
|
||||
declare module "../components/PageEditor";
|
||||
declare module "../components/Viewer";
|
||||
declare module "*.js";
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { ChangePermissionsParameters, ChangePermissionsParametersHook, useChangePermissionsParameters } from '../changePermissions/useChangePermissionsParameters';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface AddPasswordParameters {
|
||||
export interface AddPasswordParameters extends BaseParameters {
|
||||
password: string;
|
||||
ownerPassword: string;
|
||||
keyLength: number;
|
||||
@ -11,14 +12,9 @@ export interface AddPasswordFullParameters extends AddPasswordParameters {
|
||||
permissions: ChangePermissionsParameters;
|
||||
}
|
||||
|
||||
export interface AddPasswordParametersHook {
|
||||
export interface AddPasswordParametersHook extends BaseParametersHook<AddPasswordParameters> {
|
||||
fullParameters: AddPasswordFullParameters;
|
||||
parameters: AddPasswordParameters;
|
||||
permissions: ChangePermissionsParametersHook;
|
||||
updateParameter: <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
}
|
||||
|
||||
export const defaultParameters: AddPasswordParameters = {
|
||||
@ -28,42 +24,31 @@ export const defaultParameters: AddPasswordParameters = {
|
||||
};
|
||||
|
||||
export const useAddPasswordParameters = (): AddPasswordParametersHook => {
|
||||
const [parameters, setParameters] = useState<AddPasswordParameters>(defaultParameters);
|
||||
const permissions = useChangePermissionsParameters();
|
||||
|
||||
const baseHook = useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'add-password',
|
||||
validateFn: () => {
|
||||
// No required parameters for Add Password. Defer to permissions validation.
|
||||
return permissions.validateParameters();
|
||||
},
|
||||
});
|
||||
|
||||
const fullParameters: AddPasswordFullParameters = {
|
||||
...parameters,
|
||||
...baseHook.parameters,
|
||||
permissions: permissions.parameters,
|
||||
};
|
||||
|
||||
const updateParameter = <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[parameter]: value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(defaultParameters);
|
||||
baseHook.resetParameters();
|
||||
permissions.resetParameters();
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
// No required parameters for Add Password. Defer to permissions validation.
|
||||
return permissions.validateParameters();
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
return 'add-password';
|
||||
};
|
||||
|
||||
return {
|
||||
...baseHook,
|
||||
fullParameters,
|
||||
parameters,
|
||||
permissions,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { defaultWatermarkParameters } from '../../../constants/addWatermarkConstants';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface AddWatermarkParameters {
|
||||
export interface AddWatermarkParameters extends BaseParameters {
|
||||
watermarkType?: 'text' | 'image';
|
||||
watermarkText: string;
|
||||
watermarkImage?: File;
|
||||
@ -15,36 +15,34 @@ export interface AddWatermarkParameters {
|
||||
convertPDFToImage: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: AddWatermarkParameters = {
|
||||
watermarkType: undefined,
|
||||
watermarkText: '',
|
||||
fontSize: 12,
|
||||
rotation: 0,
|
||||
opacity: 50,
|
||||
widthSpacer: 50,
|
||||
heightSpacer: 50,
|
||||
alphabet: 'roman',
|
||||
customColor: '#d3d3d3',
|
||||
convertPDFToImage: false
|
||||
};
|
||||
|
||||
export const useAddWatermarkParameters = () => {
|
||||
const [parameters, setParameters] = useState<AddWatermarkParameters>(defaultWatermarkParameters);
|
||||
export type AddWatermarkParametersHook = BaseParametersHook<AddWatermarkParameters>;
|
||||
|
||||
const updateParameter = useCallback(<K extends keyof AddWatermarkParameters>(
|
||||
key: K,
|
||||
value: AddWatermarkParameters[K]
|
||||
) => {
|
||||
setParameters(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const resetParameters = useCallback(() => {
|
||||
setParameters(defaultWatermarkParameters);
|
||||
}, []);
|
||||
|
||||
const validateParameters = useCallback((): boolean => {
|
||||
if (!parameters.watermarkType) {
|
||||
return false;
|
||||
}
|
||||
if (parameters.watermarkType === 'text') {
|
||||
return parameters.watermarkText.trim().length > 0;
|
||||
} else {
|
||||
return parameters.watermarkImage !== undefined;
|
||||
}
|
||||
}, [parameters]);
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters
|
||||
};
|
||||
};
|
||||
export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters: defaultParameters,
|
||||
endpointName: 'add-watermark',
|
||||
validateFn: (params): boolean => {
|
||||
if (!params.watermarkType) {
|
||||
return false;
|
||||
}
|
||||
if (params.watermarkType === 'text') {
|
||||
return params.watermarkText.trim().length > 0;
|
||||
} else {
|
||||
return params.watermarkImage !== undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ChangePermissionsParameters {
|
||||
export interface ChangePermissionsParameters extends BaseParameters {
|
||||
preventAssembly: boolean;
|
||||
preventExtractContent: boolean;
|
||||
preventExtractForAccessibility: boolean;
|
||||
@ -11,14 +12,6 @@ export interface ChangePermissionsParameters {
|
||||
preventPrintingFaithful: boolean;
|
||||
}
|
||||
|
||||
export interface ChangePermissionsParametersHook {
|
||||
parameters: ChangePermissionsParameters;
|
||||
updateParameter: (parameter: keyof ChangePermissionsParameters, value: boolean) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
}
|
||||
|
||||
export const defaultParameters: ChangePermissionsParameters = {
|
||||
preventAssembly: false,
|
||||
preventExtractContent: false,
|
||||
@ -30,35 +23,11 @@ export const defaultParameters: ChangePermissionsParameters = {
|
||||
preventPrintingFaithful: false,
|
||||
};
|
||||
|
||||
export type ChangePermissionsParametersHook = BaseParametersHook<ChangePermissionsParameters>;
|
||||
|
||||
export const useChangePermissionsParameters = (): ChangePermissionsParametersHook => {
|
||||
const [parameters, setParameters] = useState<ChangePermissionsParameters>(defaultParameters);
|
||||
|
||||
const updateParameter = <K extends keyof ChangePermissionsParameters>(parameter: K, value: ChangePermissionsParameters[K]) => {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[parameter]: value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(defaultParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
// Always valid - any combination of permissions is allowed
|
||||
return true;
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
return 'add-password'; // Change Permissions is a fake endpoint for the Add Password tool
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'add-password', // Change Permissions is a fake endpoint for the Add Password tool
|
||||
});
|
||||
};
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
|
||||
export interface CompressParameters {
|
||||
compressionLevel: number;
|
||||
grayscale: boolean;
|
||||
expectedSize: string;
|
||||
compressionMethod: 'quality' | 'filesize';
|
||||
fileSizeValue: string;
|
||||
fileSizeUnit: 'KB' | 'MB';
|
||||
}
|
||||
import { CompressParameters } from './useCompressParameters';
|
||||
|
||||
const buildFormData = (parameters: CompressParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { CompressParameters } from './useCompressOperation';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface CompressParametersHook {
|
||||
parameters: CompressParameters;
|
||||
updateParameter: (parameter: keyof CompressParameters, value: string | boolean | number) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
export interface CompressParameters extends BaseParameters {
|
||||
compressionLevel: number;
|
||||
grayscale: boolean;
|
||||
expectedSize: string;
|
||||
compressionMethod: 'quality' | 'filesize';
|
||||
fileSizeValue: string;
|
||||
fileSizeUnit: 'KB' | 'MB';
|
||||
}
|
||||
|
||||
const initialParameters: CompressParameters = {
|
||||
const defaultParameters: CompressParameters = {
|
||||
compressionLevel: 5,
|
||||
grayscale: false,
|
||||
expectedSize: '',
|
||||
@ -18,32 +19,15 @@ const initialParameters: CompressParameters = {
|
||||
fileSizeUnit: 'MB',
|
||||
};
|
||||
|
||||
export type CompressParametersHook = BaseParametersHook<CompressParameters>;
|
||||
|
||||
export const useCompressParameters = (): CompressParametersHook => {
|
||||
const [parameters, setParameters] = useState<CompressParameters>(initialParameters);
|
||||
|
||||
const updateParameter = (parameter: keyof CompressParameters, value: string | boolean | number) => {
|
||||
setParameters(prev => ({ ...prev, [parameter]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(initialParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
// For compression, we only need to validate that compression level is within range
|
||||
// and that at least one file is selected (at least, I think that's all we need to do here)
|
||||
return parameters.compressionLevel >= 1 && parameters.compressionLevel <= 9;
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
return 'compress-pdf';
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
};
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'compress-pdf',
|
||||
validateFn: (params) => {
|
||||
// For compression, we only need to validate that compression level is within range
|
||||
return params.compressionLevel >= 1 && params.compressionLevel <= 9;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -5,14 +5,15 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useConvertParameters } from './useConvertParameters';
|
||||
import { FIT_OPTIONS } from '../../../constants/convertConstants';
|
||||
|
||||
describe('useConvertParameters', () => {
|
||||
|
||||
|
||||
describe('Parameter Management', () => {
|
||||
|
||||
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('');
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
expect(result.current.parameters.imageOptions.colorType).toBe('color');
|
||||
@ -28,46 +29,52 @@ describe('useConvertParameters', () => {
|
||||
|
||||
test('should update individual parameters', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
expect(result.current.parameters.toExtension).toBe(''); // Should not affect other params
|
||||
});
|
||||
|
||||
test('should update nested image options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('imageOptions', {
|
||||
colorType: 'grayscale',
|
||||
dpi: 150,
|
||||
singleOrMultiple: 'single'
|
||||
singleOrMultiple: 'single',
|
||||
fitOption: FIT_OPTIONS.FILL_PAGE,
|
||||
autoRotate: false,
|
||||
combineImages: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.imageOptions.colorType).toBe('grayscale');
|
||||
expect(result.current.parameters.imageOptions.dpi).toBe(150);
|
||||
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('single');
|
||||
expect(result.current.parameters.imageOptions.fitOption).toBe(FIT_OPTIONS.FILL_PAGE);
|
||||
expect(result.current.parameters.imageOptions.autoRotate).toBe(false);
|
||||
expect(result.current.parameters.imageOptions.combineImages).toBe(false);
|
||||
});
|
||||
|
||||
test('should update nested HTML options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('htmlOptions', {
|
||||
zoomLevel: 1.5
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5);
|
||||
});
|
||||
|
||||
test('should update nested email options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('emailOptions', {
|
||||
includeAttachments: false,
|
||||
@ -76,7 +83,7 @@ describe('useConvertParameters', () => {
|
||||
includeAllRecipients: true
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.emailOptions.includeAttachments).toBe(false);
|
||||
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(20);
|
||||
expect(result.current.parameters.emailOptions.downloadHtml).toBe(true);
|
||||
@ -85,49 +92,49 @@ describe('useConvertParameters', () => {
|
||||
|
||||
test('should update nested PDF/A options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('pdfaOptions', {
|
||||
outputFormat: 'pdfa'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa');
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('');
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Validation', () => {
|
||||
|
||||
|
||||
test('should validate parameters correctly', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
// No parameters - should be invalid
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
|
||||
|
||||
// Only fromExtension - should be invalid
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
|
||||
|
||||
// Both extensions with supported conversion - should be valid
|
||||
act(() => {
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
@ -137,63 +144,63 @@ describe('useConvertParameters', () => {
|
||||
|
||||
test('should validate unsupported conversions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'unsupported');
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Endpoint Generation', () => {
|
||||
|
||||
|
||||
test('should generate correct endpoint names', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
|
||||
|
||||
const endpointName = result.current.getEndpointName();
|
||||
expect(endpointName).toBe('pdf-to-img');
|
||||
});
|
||||
|
||||
test('should generate correct endpoint URLs', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
|
||||
|
||||
const endpoint = result.current.getEndpoint();
|
||||
expect(endpoint).toBe('/api/v1/convert/pdf/img');
|
||||
});
|
||||
|
||||
test('should return empty strings for invalid conversions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'invalid');
|
||||
result.current.updateParameter('toExtension', 'invalid');
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('');
|
||||
expect(result.current.getEndpoint()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Available Extensions', () => {
|
||||
|
||||
|
||||
test('should return available extensions for valid source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('pdf');
|
||||
|
||||
|
||||
expect(availableExtensions.length).toBeGreaterThan(0);
|
||||
expect(availableExtensions.some(ext => ext.value === 'png')).toBe(true);
|
||||
expect(availableExtensions.some(ext => ext.value === 'jpg')).toBe(true);
|
||||
@ -201,9 +208,9 @@ describe('useConvertParameters', () => {
|
||||
|
||||
test('should return empty array for invalid source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('invalid');
|
||||
|
||||
|
||||
expect(availableExtensions).toEqual([{
|
||||
"group": "Document",
|
||||
"label": "PDF",
|
||||
@ -213,11 +220,11 @@ describe('useConvertParameters', () => {
|
||||
|
||||
test('should return empty array for empty source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('');
|
||||
|
||||
|
||||
expect(availableExtensions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
COLOR_TYPES,
|
||||
import {
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS,
|
||||
TO_FORMAT_OPTIONS,
|
||||
@ -11,8 +10,11 @@ import {
|
||||
} from '../../../constants/convertConstants';
|
||||
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export interface ConvertParameters {
|
||||
export interface ConvertParameters extends BaseParameters {
|
||||
fromExtension: string;
|
||||
toExtension: string;
|
||||
imageOptions: {
|
||||
@ -39,18 +41,13 @@ export interface ConvertParameters {
|
||||
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
|
||||
}
|
||||
|
||||
export interface ConvertParametersHook {
|
||||
parameters: ConvertParameters;
|
||||
updateParameter: (parameter: keyof ConvertParameters, value: any) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
export interface ConvertParametersHook extends BaseParametersHook<ConvertParameters> {
|
||||
getEndpoint: () => string;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
analyzeFileTypes: (files: Array<{name: string}>) => void;
|
||||
}
|
||||
|
||||
const initialParameters: ConvertParameters = {
|
||||
const defaultParameters: ConvertParameters = {
|
||||
fromExtension: '',
|
||||
toExtension: '',
|
||||
imageOptions: {
|
||||
@ -77,67 +74,65 @@ const initialParameters: ConvertParameters = {
|
||||
smartDetectionType: 'none',
|
||||
};
|
||||
|
||||
export const useConvertParameters = (): ConvertParametersHook => {
|
||||
const [parameters, setParameters] = useState<ConvertParameters>(initialParameters);
|
||||
const validateParameters = (params: ConvertParameters): boolean => {
|
||||
const { fromExtension, toExtension } = params;
|
||||
|
||||
const updateParameter = (parameter: keyof ConvertParameters, value: any) => {
|
||||
setParameters(prev => ({ ...prev, [parameter]: value }));
|
||||
};
|
||||
if (!fromExtension || !toExtension) return false;
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(initialParameters);
|
||||
};
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
let supportedToExtensions: string[] = [];
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options
|
||||
supportedToExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
} else {
|
||||
// Regular format - check conversion matrix
|
||||
supportedToExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
}
|
||||
|
||||
const validateParameters = () => {
|
||||
const { fromExtension, toExtension } = parameters;
|
||||
|
||||
if (!fromExtension || !toExtension) return false;
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
let supportedToExtensions: string[] = [];
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options
|
||||
supportedToExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
} else {
|
||||
// Regular format - check conversion matrix
|
||||
supportedToExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
}
|
||||
|
||||
if (!supportedToExtensions.includes(toExtension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
if (!supportedToExtensions.includes(toExtension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const getEndpointName = () => {
|
||||
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters;
|
||||
|
||||
if (isSmartDetection) {
|
||||
if (smartDetectionType === 'mixed') {
|
||||
// Mixed file types -> PDF using file-to-pdf endpoint
|
||||
return 'file-to-pdf';
|
||||
} else if (smartDetectionType === 'images') {
|
||||
// All images -> PDF using img-to-pdf endpoint
|
||||
return 'img-to-pdf';
|
||||
} else if (smartDetectionType === 'web') {
|
||||
// All web files -> PDF using html-to-pdf endpoint
|
||||
return 'html-to-pdf';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use file-to-pdf endpoint
|
||||
return true;
|
||||
};
|
||||
|
||||
const getEndpointName = (params: ConvertParameters): string => {
|
||||
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = params;
|
||||
|
||||
if (isSmartDetection) {
|
||||
if (smartDetectionType === 'mixed') {
|
||||
// Mixed file types -> PDF using file-to-pdf endpoint
|
||||
return 'file-to-pdf';
|
||||
} else if (smartDetectionType === 'images') {
|
||||
// All images -> PDF using img-to-pdf endpoint
|
||||
return 'img-to-pdf';
|
||||
} else if (smartDetectionType === 'web') {
|
||||
// All web files -> PDF using html-to-pdf endpoint
|
||||
return 'html-to-pdf';
|
||||
}
|
||||
|
||||
return getEndpointNameUtil(fromExtension, toExtension);
|
||||
};
|
||||
}
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use file-to-pdf endpoint
|
||||
return 'file-to-pdf';
|
||||
}
|
||||
|
||||
return getEndpointNameUtil(fromExtension, toExtension);
|
||||
};
|
||||
|
||||
export const useConvertParameters = (): ConvertParametersHook => {
|
||||
const config = useMemo(() => ({
|
||||
defaultParameters,
|
||||
endpointName: getEndpointName,
|
||||
validateFn: validateParameters,
|
||||
}), []);
|
||||
|
||||
const baseHook = useBaseParameters(config);
|
||||
|
||||
const getEndpoint = () => {
|
||||
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters;
|
||||
|
||||
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = baseHook.parameters;
|
||||
|
||||
if (isSmartDetection) {
|
||||
if (smartDetectionType === 'mixed') {
|
||||
// Mixed file types -> PDF using file-to-pdf endpoint
|
||||
@ -150,60 +145,67 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
||||
return '/api/v1/convert/html/pdf';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use file-to-pdf endpoint
|
||||
return '/api/v1/convert/file/pdf';
|
||||
}
|
||||
|
||||
|
||||
return getEndpointUrl(fromExtension, toExtension);
|
||||
};
|
||||
|
||||
const getAvailableToExtensions = (fromExtension: string) => {
|
||||
if (!fromExtension) return [];
|
||||
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options (file-to-pdf)
|
||||
const supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
|
||||
// If no explicit conversion exists, but file-to-pdf might be available,
|
||||
|
||||
// If no explicit conversion exists, but file-to-pdf might be available,
|
||||
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
|
||||
if (supportedExtensions.length === 0 && fromExtension !== 'any') {
|
||||
supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const analyzeFileTypes = (files: Array<{name: string}>) => {
|
||||
const analyzeFileTypes = useCallback((files: Array<{name: string}>) => {
|
||||
if (files.length === 0) {
|
||||
// No files - only reset smart detection, keep user's format choices
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
// Don't reset fromExtension and toExtension - let user keep their choices
|
||||
}));
|
||||
baseHook.setParameters(prev => {
|
||||
// Only update if something actually changed
|
||||
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
|
||||
return prev; // No change needed
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
// Don't reset fromExtension and toExtension - let user keep their choices
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (files.length === 1) {
|
||||
// Single file - use regular detection with smart target selection
|
||||
const detectedExt = detectFileExtensionUtil(files[0].name);
|
||||
let fromExt = detectedExt;
|
||||
let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : [];
|
||||
|
||||
|
||||
// If no explicit conversion exists for this file type, create a dynamic format entry
|
||||
// and fall back to 'any' conversion logic for the actual endpoint
|
||||
if (availableTargets.length === 0 && detectedExt) {
|
||||
@ -214,28 +216,40 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
||||
fromExt = 'any';
|
||||
availableTargets = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
setParameters(prev => {
|
||||
|
||||
baseHook.setParameters(prev => {
|
||||
// Check if current toExtension is still valid for the new fromExtension
|
||||
const currentToExt = prev.toExtension;
|
||||
const isCurrentToExtValid = availableTargets.includes(currentToExt);
|
||||
|
||||
|
||||
// Auto-select target only if:
|
||||
// 1. No current target is set, OR
|
||||
// 2. Current target is invalid for new source type, OR
|
||||
// 2. Current target is invalid for new source type, OR
|
||||
// 3. There's only one possible target (forced conversion)
|
||||
let newToExtension = currentToExt;
|
||||
if (!currentToExt || !isCurrentToExtValid) {
|
||||
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
const newState = {
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none',
|
||||
smartDetectionType: 'none' as const,
|
||||
fromExtension: fromExt,
|
||||
toExtension: newToExtension
|
||||
};
|
||||
|
||||
// Only update if something actually changed
|
||||
if (
|
||||
prev.isSmartDetection === newState.isSmartDetection &&
|
||||
prev.smartDetectionType === newState.smartDetectionType &&
|
||||
prev.fromExtension === newState.fromExtension &&
|
||||
prev.toExtension === newState.toExtension
|
||||
) {
|
||||
return prev; // Return the same object to prevent re-render
|
||||
}
|
||||
|
||||
return newState;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -249,79 +263,117 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
||||
const detectedExt = uniqueExtensions[0];
|
||||
let fromExt = detectedExt;
|
||||
let availableTargets = CONVERSION_MATRIX[detectedExt] || [];
|
||||
|
||||
|
||||
// If no explicit conversion exists for this file type, fall back to 'any'
|
||||
if (availableTargets.length === 0) {
|
||||
fromExt = 'any';
|
||||
availableTargets = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
setParameters(prev => {
|
||||
|
||||
baseHook.setParameters(prev => {
|
||||
// Check if current toExtension is still valid for the new fromExtension
|
||||
const currentToExt = prev.toExtension;
|
||||
const isCurrentToExtValid = availableTargets.includes(currentToExt);
|
||||
|
||||
|
||||
// Auto-select target only if:
|
||||
// 1. No current target is set, OR
|
||||
// 2. Current target is invalid for new source type, OR
|
||||
// 2. Current target is invalid for new source type, OR
|
||||
// 3. There's only one possible target (forced conversion)
|
||||
let newToExtension = currentToExt;
|
||||
if (!currentToExt || !isCurrentToExtValid) {
|
||||
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
const newState = {
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none',
|
||||
smartDetectionType: 'none' as const,
|
||||
fromExtension: fromExt,
|
||||
toExtension: newToExtension
|
||||
};
|
||||
|
||||
// Only update if something actually changed
|
||||
if (
|
||||
prev.isSmartDetection === newState.isSmartDetection &&
|
||||
prev.smartDetectionType === newState.smartDetectionType &&
|
||||
prev.fromExtension === newState.fromExtension &&
|
||||
prev.toExtension === newState.toExtension
|
||||
) {
|
||||
return prev; // Return the same object to prevent re-render
|
||||
}
|
||||
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
// Mixed file types
|
||||
const allImages = uniqueExtensions.every(ext => isImageFormat(ext));
|
||||
const allWeb = uniqueExtensions.every(ext => isWebFormat(ext));
|
||||
|
||||
|
||||
if (allImages) {
|
||||
// All files are images - use image-to-pdf conversion
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'images',
|
||||
fromExtension: 'image',
|
||||
toExtension: 'pdf'
|
||||
}));
|
||||
baseHook.setParameters(prev => {
|
||||
// Only update if something actually changed
|
||||
if (prev.isSmartDetection === true &&
|
||||
prev.smartDetectionType === 'images' &&
|
||||
prev.fromExtension === 'image' &&
|
||||
prev.toExtension === 'pdf') {
|
||||
return prev; // No change needed
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'images',
|
||||
fromExtension: 'image',
|
||||
toExtension: 'pdf'
|
||||
};
|
||||
});
|
||||
} else if (allWeb) {
|
||||
// All files are web files - use html-to-pdf conversion
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'web',
|
||||
fromExtension: 'html',
|
||||
toExtension: 'pdf'
|
||||
}));
|
||||
baseHook.setParameters(prev => {
|
||||
// Only update if something actually changed
|
||||
if (prev.isSmartDetection === true &&
|
||||
prev.smartDetectionType === 'web' &&
|
||||
prev.fromExtension === 'html' &&
|
||||
prev.toExtension === 'pdf') {
|
||||
return prev; // No change needed
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'web',
|
||||
fromExtension: 'html',
|
||||
toExtension: 'pdf'
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Mixed non-image types - use file-to-pdf conversion
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'mixed',
|
||||
fromExtension: 'any',
|
||||
toExtension: 'pdf'
|
||||
}));
|
||||
// Mixed non-image types - use file-to-pdf conversion
|
||||
baseHook.setParameters(prev => {
|
||||
// Only update if something actually changed
|
||||
if (prev.isSmartDetection === true &&
|
||||
prev.smartDetectionType === 'mixed' &&
|
||||
prev.fromExtension === 'any' &&
|
||||
prev.toExtension === 'pdf') {
|
||||
return prev; // No change needed
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'mixed',
|
||||
fromExtension: 'any',
|
||||
toExtension: 'pdf'
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [baseHook.setParameters]);
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
...baseHook,
|
||||
getEndpoint,
|
||||
getAvailableToExtensions,
|
||||
analyzeFileTypes,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
||||
import { OCRParameters } from './useOCRParameters';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface OCRParametersHook {
|
||||
parameters: OCRParameters;
|
||||
updateParameter: (key: keyof OCRParameters, value: any) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
export interface OCRParameters extends BaseParameters {
|
||||
languages: string[];
|
||||
ocrType: string;
|
||||
ocrRenderType: string;
|
||||
additionalOptions: string[];
|
||||
}
|
||||
|
||||
export type OCRParametersHook = BaseParametersHook<OCRParameters>;
|
||||
|
||||
const defaultParameters: OCRParameters = {
|
||||
languages: [],
|
||||
ocrType: 'skip-text',
|
||||
@ -16,28 +18,12 @@ const defaultParameters: OCRParameters = {
|
||||
};
|
||||
|
||||
export const useOCRParameters = (): OCRParametersHook => {
|
||||
const [parameters, setParameters] = useState<OCRParameters>(defaultParameters);
|
||||
|
||||
const updateParameter = (key: keyof OCRParameters, value: any) => {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(defaultParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
// At minimum, we need at least one language selected
|
||||
return parameters.languages.length > 0;
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
};
|
||||
};
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'ocr-pdf',
|
||||
validateFn: (params) => {
|
||||
// At minimum, we need at least one language selected
|
||||
return params.languages.length > 0;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveCertificateSignParameters } from './useRemoveCertificateSignParameters';
|
||||
|
||||
export const useRemoveCertificateSignOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<RemoveCertificateSignParameters>({
|
||||
operationType: 'removeCertificateSign',
|
||||
endpoint: '/api/v1/security/remove-cert-sign',
|
||||
buildFormData,
|
||||
filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface RemoveCertificateSignParameters extends BaseParameters {
|
||||
// Extends BaseParameters - ready for future parameter additions if needed
|
||||
}
|
||||
|
||||
export const defaultParameters: RemoveCertificateSignParameters = {
|
||||
// No parameters needed
|
||||
};
|
||||
|
||||
export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCertificateSignParameters>;
|
||||
|
||||
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-certificate-sign',
|
||||
});
|
||||
};
|
@ -1,49 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface RemovePasswordParameters {
|
||||
export interface RemovePasswordParameters extends BaseParameters {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RemovePasswordParametersHook {
|
||||
parameters: RemovePasswordParameters;
|
||||
updateParameter: <K extends keyof RemovePasswordParameters>(parameter: K, value: RemovePasswordParameters[K]) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
}
|
||||
export type RemovePasswordParametersHook = BaseParametersHook<RemovePasswordParameters>;
|
||||
|
||||
export const defaultParameters: RemovePasswordParameters = {
|
||||
password: '',
|
||||
};
|
||||
|
||||
export const useRemovePasswordParameters = (): RemovePasswordParametersHook => {
|
||||
const [parameters, setParameters] = useState<RemovePasswordParameters>(defaultParameters);
|
||||
|
||||
const updateParameter = <K extends keyof RemovePasswordParameters>(parameter: K, value: RemovePasswordParameters[K]) => {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[parameter]: value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(defaultParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
return parameters.password !== '';
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
return 'remove-password';
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-password',
|
||||
validateFn: (params) => {
|
||||
return params.password !== '';
|
||||
},
|
||||
});
|
||||
};
|
||||
|
23
frontend/src/hooks/tools/repair/useRepairOperation.ts
Normal file
23
frontend/src/hooks/tools/repair/useRepairOperation.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RepairParameters } from './useRepairParameters';
|
||||
|
||||
export const useRepairOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: RepairParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<RepairParameters>({
|
||||
operationType: 'repair',
|
||||
endpoint: '/api/v1/misc/repair',
|
||||
buildFormData,
|
||||
filePrefix: t('repair.filenamePrefix', 'repaired') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
|
||||
});
|
||||
};
|
20
frontend/src/hooks/tools/repair/useRepairParameters.ts
Normal file
20
frontend/src/hooks/tools/repair/useRepairParameters.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface RepairParameters extends BaseParameters {
|
||||
// Extends BaseParameters - ready for future parameter additions if needed
|
||||
}
|
||||
|
||||
export const defaultParameters: RepairParameters = {
|
||||
// No parameters needed
|
||||
};
|
||||
|
||||
export type RepairParametersHook = BaseParametersHook<RepairParameters>;
|
||||
|
||||
export const useRepairParameters = (): RepairParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'repair',
|
||||
// validateFn: optional custom validation if needed in future
|
||||
});
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface SanitizeParameters {
|
||||
export interface SanitizeParameters extends BaseParameters {
|
||||
removeJavaScript: boolean;
|
||||
removeEmbeddedFiles: boolean;
|
||||
removeXMPMetadata: boolean;
|
||||
@ -18,36 +19,14 @@ export const defaultParameters: SanitizeParameters = {
|
||||
removeFonts: false,
|
||||
};
|
||||
|
||||
export const useSanitizeParameters = () => {
|
||||
const [parameters, setParameters] = useState<SanitizeParameters>(defaultParameters);
|
||||
export type SanitizeParametersHook = BaseParametersHook<SanitizeParameters>;
|
||||
|
||||
const updateParameter = useCallback(<K extends keyof SanitizeParameters>(
|
||||
key: K,
|
||||
value: SanitizeParameters[K]
|
||||
) => {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetParameters = useCallback(() => {
|
||||
setParameters(defaultParameters);
|
||||
}, []);
|
||||
|
||||
const validateParameters = useCallback(() => {
|
||||
return Object.values(parameters).some(value => value === true);
|
||||
}, [parameters]);
|
||||
|
||||
const getEndpointName = () => {
|
||||
return 'sanitize-pdf'
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
export const useSanitizeParameters = (): SanitizeParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'sanitize-pdf',
|
||||
validateFn: (params) => {
|
||||
return Object.values(params).some(value => value === true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
56
frontend/src/hooks/tools/shared/useBaseParameters.ts
Normal file
56
frontend/src/hooks/tools/shared/useBaseParameters.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export interface BaseParametersHook<T> {
|
||||
parameters: T;
|
||||
setParameters: Dispatch<SetStateAction<T>>;
|
||||
updateParameter: <K extends keyof T>(parameter: K, value: T[K]) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
}
|
||||
|
||||
export interface BaseParametersConfig<T> {
|
||||
defaultParameters: T;
|
||||
endpointName: string | ((params: T) => string);
|
||||
validateFn?: (params: T) => boolean;
|
||||
}
|
||||
|
||||
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
|
||||
const [parameters, setParameters] = useState<T>(config.defaultParameters);
|
||||
|
||||
const updateParameter = useCallback(<K extends keyof T>(parameter: K, value: T[K]) => {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[parameter]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetParameters = useCallback(() => {
|
||||
setParameters(config.defaultParameters);
|
||||
}, [config.defaultParameters]);
|
||||
|
||||
const validateParameters = useCallback(() => {
|
||||
return config.validateFn ? config.validateFn(parameters) : true;
|
||||
}, [parameters, config.validateFn]);
|
||||
|
||||
const endpointName = config.endpointName;
|
||||
let getEndpointName: () => string;
|
||||
if (typeof endpointName === "string") {
|
||||
getEndpointName = useCallback(() => {
|
||||
return endpointName;
|
||||
}, []);
|
||||
} else {
|
||||
getEndpointName = useCallback(() => {
|
||||
return endpointName(parameters);
|
||||
}, [parameters]);
|
||||
}
|
||||
|
||||
return {
|
||||
parameters,
|
||||
setParameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
}
|
@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
@ -198,8 +198,9 @@ export const useToolOperation = <TParams = void>(
|
||||
actions.setThumbnails(thumbnails);
|
||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||
|
||||
// Consume input files and add output files (will replace unpinned inputs)
|
||||
await consumeFiles(validFiles, processedFiles);
|
||||
// Replace input files with processed files (consumeFiles handles pinning)
|
||||
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as string[];
|
||||
await consumeFiles(inputFileIds, processedFiles);
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
}
|
||||
@ -213,7 +214,7 @@ export const useToolOperation = <TParams = void>(
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
cancelApiCalls();
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
|
||||
|
||||
@ -11,20 +11,28 @@ export const useToolResources = () => {
|
||||
}, []);
|
||||
|
||||
const cleanupBlobUrls = useCallback(() => {
|
||||
blobUrls.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.warn('Failed to revoke blob URL:', error);
|
||||
}
|
||||
setBlobUrls(prev => {
|
||||
prev.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.warn('Failed to revoke blob URL:', error);
|
||||
}
|
||||
});
|
||||
return [];
|
||||
});
|
||||
setBlobUrls([]);
|
||||
}, [blobUrls]);
|
||||
}, []); // No dependencies - use functional update pattern
|
||||
|
||||
// Cleanup on unmount
|
||||
// Cleanup on unmount - use ref to avoid dependency on blobUrls state
|
||||
const blobUrlsRef = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
blobUrlsRef.current = blobUrls;
|
||||
}, [blobUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobUrls.forEach(url => {
|
||||
blobUrlsRef.current.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
@ -32,19 +40,20 @@ export const useToolResources = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [blobUrls]);
|
||||
}, []); // No dependencies - use ref to access current URLs
|
||||
|
||||
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
|
||||
console.log(`🖼️ useToolResources.generateThumbnails: Starting for ${files.length} files`);
|
||||
const thumbnails: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
console.log(`🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (thumbnail) {
|
||||
thumbnails.push(thumbnail);
|
||||
}
|
||||
console.log(`🖼️ Generated thumbnail for ${file.name}: SUCCESS`);
|
||||
thumbnails.push(thumbnail);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
console.warn(`🖼️ Failed to generate thumbnail for ${file.name}:`, error);
|
||||
thumbnails.push('');
|
||||
}
|
||||
}
|
||||
@ -52,6 +61,26 @@ export const useToolResources = () => {
|
||||
return thumbnails;
|
||||
}, []);
|
||||
|
||||
const generateThumbnailsWithMetadata = useCallback(async (files: File[]): Promise<ThumbnailWithMetadata[]> => {
|
||||
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Starting for ${files.length} files`);
|
||||
const results: ThumbnailWithMetadata[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
console.log(`🖼️ Generating thumbnail with metadata for: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
console.log(`🖼️ Generated thumbnail with metadata for ${file.name}: SUCCESS, ${result.pageCount} pages`);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.warn(`🖼️ Failed to generate thumbnail with metadata for ${file.name}:`, error);
|
||||
results.push({ thumbnail: '', pageCount: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Complete. Generated ${results.length}/${files.length} thumbnails with metadata`);
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||
try {
|
||||
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
||||
@ -108,6 +137,7 @@ export const useToolResources = () => {
|
||||
|
||||
return {
|
||||
generateThumbnails,
|
||||
generateThumbnailsWithMetadata,
|
||||
createDownloadInfo,
|
||||
extractZipFiles,
|
||||
extractAllZipFiles,
|
||||
|
@ -88,6 +88,8 @@ export const useToolState = () => {
|
||||
}, []);
|
||||
|
||||
const setThumbnails = useCallback((thumbnails: string[]) => {
|
||||
console.log(`🔧 useToolState.setThumbnails: Setting ${thumbnails.length} thumbnails:`,
|
||||
thumbnails.map((t, i) => `[${i}]: ${t ? 'PRESENT' : 'MISSING'}`));
|
||||
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
|
||||
}, []);
|
||||
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SingleLargePageParameters } from './useSingleLargePageParameters';
|
||||
|
||||
export const useSingleLargePageOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<SingleLargePageParameters>({
|
||||
operationType: 'singleLargePage',
|
||||
endpoint: '/api/v1/general/pdf-to-single-page',
|
||||
buildFormData,
|
||||
filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface SingleLargePageParameters extends BaseParameters {
|
||||
// Extends BaseParameters - ready for future parameter additions if needed
|
||||
}
|
||||
|
||||
export const defaultParameters: SingleLargePageParameters = {
|
||||
// No parameters needed
|
||||
};
|
||||
|
||||
export type SingleLargePageParametersHook = BaseParametersHook<SingleLargePageParameters>;
|
||||
|
||||
export const useSingleLargePageParameters = (): SingleLargePageParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'pdf-to-single-page',
|
||||
});
|
||||
};
|
@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, SplitType } from '../../../constants/splitConstants';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface SplitParameters {
|
||||
export interface SplitParameters extends BaseParameters {
|
||||
mode: SplitMode | '';
|
||||
pages: string;
|
||||
hDiv: string;
|
||||
@ -14,15 +15,9 @@ export interface SplitParameters {
|
||||
allowDuplicates: boolean;
|
||||
}
|
||||
|
||||
export interface SplitParametersHook {
|
||||
parameters: SplitParameters;
|
||||
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
}
|
||||
export type SplitParametersHook = BaseParametersHook<SplitParameters>;
|
||||
|
||||
const initialParameters: SplitParameters = {
|
||||
const defaultParameters: SplitParameters = {
|
||||
mode: '',
|
||||
pages: '',
|
||||
hDiv: '2',
|
||||
@ -36,43 +31,27 @@ const initialParameters: SplitParameters = {
|
||||
};
|
||||
|
||||
export const useSplitParameters = (): SplitParametersHook => {
|
||||
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: (params) => {
|
||||
if (!params.mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
||||
return ENDPOINTS[params.mode as SplitMode];
|
||||
},
|
||||
validateFn: (params) => {
|
||||
if (!params.mode) return false;
|
||||
|
||||
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
|
||||
setParameters(prev => ({ ...prev, [parameter]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(initialParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
if (!parameters.mode) return false;
|
||||
|
||||
switch (parameters.mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
return parameters.pages.trim() !== "";
|
||||
case SPLIT_MODES.BY_SECTIONS:
|
||||
return parameters.hDiv !== "" && parameters.vDiv !== "";
|
||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||
return parameters.splitValue.trim() !== "";
|
||||
case SPLIT_MODES.BY_CHAPTERS:
|
||||
return parameters.bookmarkLevel !== "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
if (!parameters.mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
||||
return ENDPOINTS[parameters.mode as SplitMode];
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
switch (params.mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
return params.pages.trim() !== "";
|
||||
case SPLIT_MODES.BY_SECTIONS:
|
||||
return params.hDiv !== "" && params.vDiv !== "";
|
||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||
return params.splitValue.trim() !== "";
|
||||
case SPLIT_MODES.BY_CHAPTERS:
|
||||
return params.bookmarkLevel !== "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { UnlockPdfFormsParameters } from './useUnlockPdfFormsParameters';
|
||||
|
||||
export const useUnlockPdfFormsOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<UnlockPdfFormsParameters>({
|
||||
operationType: 'unlockPdfForms',
|
||||
endpoint: '/api/v1/misc/unlock-pdf-forms',
|
||||
buildFormData,
|
||||
filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface UnlockPdfFormsParameters extends BaseParameters {
|
||||
// Extends BaseParameters - ready for future parameter additions if needed
|
||||
}
|
||||
|
||||
export const defaultParameters: UnlockPdfFormsParameters = {
|
||||
// No parameters needed
|
||||
};
|
||||
|
||||
export type UnlockPdfFormsParametersHook = BaseParametersHook<UnlockPdfFormsParameters>;
|
||||
|
||||
export const useUnlockPdfFormsParameters = (): UnlockPdfFormsParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'unlock-pdf-forms',
|
||||
});
|
||||
};
|
@ -1,27 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFileContext } from '../contexts/FileContext';
|
||||
import { useFileState, useFileActions } from '../contexts/FileContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
|
||||
export const useFileHandler = () => {
|
||||
const { activeFiles, addFiles } = useFileContext();
|
||||
const { state } = useFileState(); // Still needed for addStoredFiles
|
||||
const { actions } = useFileActions();
|
||||
|
||||
const addToActiveFiles = useCallback(async (file: File) => {
|
||||
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
|
||||
if (!exists) {
|
||||
await addFiles([file]);
|
||||
}
|
||||
}, [activeFiles, addFiles]);
|
||||
// Let FileContext handle deduplication with quickKey logic
|
||||
await actions.addFiles([file]);
|
||||
}, [actions.addFiles]);
|
||||
|
||||
const addMultipleFiles = useCallback(async (files: File[]) => {
|
||||
const newFiles = files.filter(file =>
|
||||
!activeFiles.some(f => f.name === file.name && f.size === file.size)
|
||||
);
|
||||
// Let FileContext handle deduplication with quickKey logic
|
||||
await actions.addFiles(files);
|
||||
}, [actions.addFiles]);
|
||||
|
||||
// Add stored files preserving their original IDs to prevent session duplicates
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||
// Filter out files that already exist with the same ID (exact match)
|
||||
const newFiles = filesWithMetadata.filter(({ originalId }) => {
|
||||
return state.files.byId[originalId] === undefined;
|
||||
});
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
await addFiles(newFiles);
|
||||
await actions.addStoredFiles(newFiles);
|
||||
}
|
||||
}, [activeFiles, addFiles]);
|
||||
|
||||
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
|
||||
}, [state.files.byId, actions.addStoredFiles]);
|
||||
|
||||
return {
|
||||
addToActiveFiles,
|
||||
addMultipleFiles,
|
||||
addStoredFiles,
|
||||
};
|
||||
};
|
@ -1,84 +1,125 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import { createEnhancedFileFromStored } from '../utils/fileUtils';
|
||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
|
||||
const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise<File> => {
|
||||
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
|
||||
const response = await fetch(fileWithUrl.url);
|
||||
const data = await response.arrayBuffer();
|
||||
const file = new File([data], fileWithUrl.name, {
|
||||
type: fileWithUrl.type || 'application/pdf',
|
||||
lastModified: fileWithUrl.lastModified || Date.now()
|
||||
});
|
||||
// Preserve the ID if it exists
|
||||
if (fileWithUrl.id) {
|
||||
Object.defineProperty(file, 'id', { value: fileWithUrl.id, writable: false });
|
||||
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
|
||||
if (!indexedDB) {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
|
||||
// Handle drafts differently from regular files
|
||||
if (fileMetadata.isDraft) {
|
||||
// Load draft from the drafts database
|
||||
try {
|
||||
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
|
||||
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['drafts'], 'readonly');
|
||||
const store = transaction.objectStore('drafts');
|
||||
const request = store.get(fileMetadata.id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const draft = request.result;
|
||||
if (draft && draft.pdfData) {
|
||||
const file = new File([draft.pdfData], fileMetadata.name, {
|
||||
type: 'application/pdf',
|
||||
lastModified: fileMetadata.lastModified
|
||||
});
|
||||
resolve(file);
|
||||
} else {
|
||||
reject(new Error('Draft data not found'));
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// Always use ID first, fallback to name only if ID doesn't exist
|
||||
const lookupKey = fileWithUrl.id || fileWithUrl.name;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
if (storedFile) {
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
return file;
|
||||
|
||||
// Regular file loading
|
||||
if (fileMetadata.id) {
|
||||
const file = await indexedDB.loadFile(fileMetadata.id);
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
|
||||
}, [indexedDB]);
|
||||
|
||||
throw new Error('File not found in storage');
|
||||
}, []);
|
||||
|
||||
const loadRecentFiles = useCallback(async (): Promise<FileWithUrl[]> => {
|
||||
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const files = await fileStorage.getAllFiles();
|
||||
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
return sortedFiles.map(file => createEnhancedFileFromStored(file));
|
||||
if (!indexedDB) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Load regular files metadata only
|
||||
const storedFileMetadata = await indexedDB.loadAllMetadata();
|
||||
|
||||
// For now, only regular files - drafts will be handled separately in the future
|
||||
const allFiles = storedFileMetadata;
|
||||
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent files:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [indexedDB]);
|
||||
|
||||
const handleRemoveFile = useCallback(async (index: number, files: FileWithUrl[], setFiles: (files: FileWithUrl[]) => void) => {
|
||||
const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => {
|
||||
const file = files[index];
|
||||
if (!file.id) {
|
||||
throw new Error('File ID is required for removal');
|
||||
}
|
||||
if (!indexedDB) {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
try {
|
||||
await fileStorage.deleteFile(file.id || file.name);
|
||||
await indexedDB.deleteFile(file.id);
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove file:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
}, [indexedDB]);
|
||||
|
||||
const storeFile = useCallback(async (file: File) => {
|
||||
const storeFile = useCallback(async (file: File, fileId: string) => {
|
||||
if (!indexedDB) {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
try {
|
||||
// Generate thumbnail for the file
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
// Store file with provided UUID from FileContext (thumbnail generated internally)
|
||||
const metadata = await indexedDB.saveFile(file, fileId);
|
||||
|
||||
// Store file with thumbnail
|
||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
return storedFile;
|
||||
// Convert file to ArrayBuffer for StoredFile interface compatibility
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Return StoredFile format for compatibility with old API
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
data: arrayBuffer,
|
||||
thumbnail: metadata.thumbnail
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to store file:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
}, [indexedDB]);
|
||||
|
||||
const createFileSelectionHandlers = useCallback((
|
||||
selectedFiles: string[],
|
||||
@ -96,14 +137,23 @@ export const useFileManager = () => {
|
||||
setSelectedFiles([]);
|
||||
};
|
||||
|
||||
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
|
||||
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name));
|
||||
const filePromises = selectedFileObjects.map(convertToFile);
|
||||
const convertedFiles = await Promise.all(filePromises);
|
||||
onFilesSelect(convertedFiles);
|
||||
// Filter by UUID and convert to File objects
|
||||
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
|
||||
|
||||
// Use stored files flow that preserves IDs
|
||||
const filesWithMetadata = await Promise.all(
|
||||
selectedFileObjects.map(async (metadata) => ({
|
||||
file: await convertToFile(metadata),
|
||||
originalId: metadata.id,
|
||||
metadata
|
||||
}))
|
||||
);
|
||||
onStoredFilesSelect(filesWithMetadata);
|
||||
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
console.error('Failed to load selected files:', error);
|
||||
@ -119,12 +169,18 @@ export const useFileManager = () => {
|
||||
}, [convertToFile]);
|
||||
|
||||
const touchFile = useCallback(async (id: string) => {
|
||||
if (!indexedDB) {
|
||||
console.warn('IndexedDB context not available for touch operation');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fileStorage.touchFile(id);
|
||||
// Update access time - this will be handled by the cache in IndexedDBContext
|
||||
// when the file is loaded, so we can just load it briefly to "touch" it
|
||||
await indexedDB.loadFile(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to touch file:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [indexedDB]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
import { FileMetadata } from "../types/file";
|
||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
|
||||
/**
|
||||
@ -22,12 +22,13 @@ function calculateThumbnailScale(pageViewport: { width: number; height: number }
|
||||
* Hook for IndexedDB-aware thumbnail loading
|
||||
* Handles thumbnail generation for files not in IndexedDB
|
||||
*/
|
||||
export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
thumbnail: string | null;
|
||||
isGenerating: boolean
|
||||
} {
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -44,46 +45,36 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
return;
|
||||
}
|
||||
|
||||
// Second priority: generate thumbnail for any file type
|
||||
// Second priority: generate thumbnail for files under 100MB
|
||||
if (file.size < 100 * 1024 * 1024 && !generating) {
|
||||
setGenerating(true);
|
||||
try {
|
||||
let fileObject: File;
|
||||
|
||||
// Handle IndexedDB files vs regular File objects
|
||||
if (file.storedInIndexedDB && file.id) {
|
||||
// For IndexedDB files, recreate File object from stored data
|
||||
const storedFile = await fileStorage.getFile(file.id);
|
||||
if (!storedFile) {
|
||||
// Try to load file from IndexedDB using new context
|
||||
if (file.id && indexedDB) {
|
||||
const loadedFile = await indexedDB.loadFile(file.id);
|
||||
if (!loadedFile) {
|
||||
throw new Error('File not found in IndexedDB');
|
||||
}
|
||||
fileObject = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
} else if ((file as any /* Fix me */).file) {
|
||||
// For FileWithUrl objects that have a File object
|
||||
fileObject = (file as any /* Fix me */).file;
|
||||
} else if (file.id) {
|
||||
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
|
||||
const storedFile = await fileStorage.getFile(file.id);
|
||||
if (!storedFile) {
|
||||
throw new Error('File not found in IndexedDB and no File object available');
|
||||
}
|
||||
fileObject = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
fileObject = loadedFile;
|
||||
} else {
|
||||
throw new Error('File object not available and no ID for IndexedDB lookup');
|
||||
throw new Error('File ID not available or IndexedDB context not available');
|
||||
}
|
||||
|
||||
// Use the universal thumbnail generator
|
||||
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||
if (!cancelled && thumbnail) {
|
||||
if (!cancelled) {
|
||||
setThumb(thumbnail);
|
||||
} else if (!cancelled) {
|
||||
setThumb(null);
|
||||
|
||||
// Save thumbnail to IndexedDB for persistence
|
||||
if (file.id && indexedDB && thumbnail) {
|
||||
try {
|
||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for file', file.name, error);
|
||||
@ -92,14 +83,14 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
if (!cancelled) setGenerating(false);
|
||||
}
|
||||
} else {
|
||||
// Large files - generate placeholder
|
||||
// Large files - no thumbnail
|
||||
setThumb(null);
|
||||
}
|
||||
}
|
||||
|
||||
loadThumbnail();
|
||||
return () => { cancelled = true; };
|
||||
}, [file, file?.thumbnail, file?.id]);
|
||||
}, [file, file?.thumbnail, file?.id, indexedDB, generating]);
|
||||
|
||||
return { thumbnail: thumb, isGenerating: generating };
|
||||
}
|
||||
|
@ -1,30 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFileContext } from '../contexts/FileContext';
|
||||
|
||||
/**
|
||||
* Hook for components that need to register resources with centralized memory management
|
||||
*/
|
||||
export function useMemoryManagement() {
|
||||
const { trackBlobUrl, trackPdfDocument, scheduleCleanup } = useFileContext();
|
||||
|
||||
const registerBlobUrl = useCallback((url: string) => {
|
||||
trackBlobUrl(url);
|
||||
return url;
|
||||
}, [trackBlobUrl]);
|
||||
|
||||
const registerPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
|
||||
trackPdfDocument(fileId, pdfDoc);
|
||||
return pdfDoc;
|
||||
}, [trackPdfDocument]);
|
||||
|
||||
const cancelCleanup = useCallback((fileId: string) => {
|
||||
// Cancel scheduled cleanup (user is actively using the file)
|
||||
scheduleCleanup(fileId, -1); // -1 cancels the timer
|
||||
}, [scheduleCleanup]);
|
||||
|
||||
return {
|
||||
registerBlobUrl,
|
||||
registerPdfDocument,
|
||||
cancelCleanup
|
||||
};
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getDocument } from 'pdfjs-dist';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
|
||||
export function usePDFProcessor() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -13,7 +13,7 @@ export function usePDFProcessor() {
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
@ -29,8 +29,8 @@ export function usePDFProcessor() {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
// Clean up using worker manager
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
return thumbnail;
|
||||
} catch (error) {
|
||||
@ -39,13 +39,35 @@ export function usePDFProcessor() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Internal function to generate thumbnail from already-opened PDF
|
||||
const generateThumbnailFromPDF = useCallback(async (
|
||||
pdf: any,
|
||||
pageNumber: number,
|
||||
scale: number = 0.5
|
||||
): Promise<string> => {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
return canvas.toDataURL();
|
||||
}, []);
|
||||
|
||||
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
@ -61,19 +83,19 @@ export function usePDFProcessor() {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate thumbnails for first 10 pages immediately for better UX
|
||||
// Generate thumbnails for first 10 pages immediately using the same PDF instance
|
||||
const priorityPages = Math.min(10, totalPages);
|
||||
for (let i = 1; i <= priorityPages; i++) {
|
||||
try {
|
||||
const thumbnail = await generatePageThumbnail(file, i);
|
||||
const thumbnail = await generateThumbnailFromPDF(pdf, i);
|
||||
pages[i - 1].thumbnail = thumbnail;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
// Clean up using worker manager
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
const document: PDFDocument = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
@ -91,7 +113,7 @@ export function usePDFProcessor() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [generatePageThumbnail]);
|
||||
}, [generateThumbnailFromPDF]);
|
||||
|
||||
return {
|
||||
processPDFFile,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
|
||||
export interface PdfSignatureDetectionResult {
|
||||
hasDigitalSignatures: boolean;
|
||||
@ -21,14 +22,12 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
|
||||
let foundSignature = false;
|
||||
|
||||
try {
|
||||
// Set up PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-legacy/pdf.worker.mjs';
|
||||
|
||||
for (const file of files) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
try {
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
@ -42,6 +41,9 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
|
||||
|
||||
if (foundSignature) break;
|
||||
}
|
||||
|
||||
// Clean up PDF document using worker manager
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
} catch (error) {
|
||||
console.warn('Error analyzing PDF for signatures:', error);
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import CropIcon from '@mui/icons-material/Crop';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
|
||||
export interface SuggestedTool {
|
||||
name: string;
|
||||
id: string /* FIX ME: Should be ToolId */;
|
||||
title: string;
|
||||
icon: React.ComponentType<any>;
|
||||
navigate: () => void;
|
||||
@ -17,27 +17,27 @@ export interface SuggestedTool {
|
||||
|
||||
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||
{
|
||||
name: 'compress',
|
||||
id: 'compress',
|
||||
title: 'Compress',
|
||||
icon: CompressIcon
|
||||
},
|
||||
{
|
||||
name: 'convert',
|
||||
id: 'convert',
|
||||
title: 'Convert',
|
||||
icon: SwapHorizIcon
|
||||
},
|
||||
{
|
||||
name: 'sanitize',
|
||||
id: 'sanitize',
|
||||
title: 'Sanitize',
|
||||
icon: CleaningServicesIcon
|
||||
},
|
||||
{
|
||||
name: 'split',
|
||||
id: 'split',
|
||||
title: 'Split',
|
||||
icon: CropIcon
|
||||
},
|
||||
{
|
||||
name: 'ocr',
|
||||
id: 'ocr',
|
||||
title: 'OCR',
|
||||
icon: TextFieldsIcon
|
||||
}
|
||||
@ -48,12 +48,12 @@ export function useSuggestedTools(): SuggestedTool[] {
|
||||
|
||||
return useMemo(() => {
|
||||
// Filter out the current tool
|
||||
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.name !== selectedToolKey);
|
||||
|
||||
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedToolKey);
|
||||
|
||||
// Add navigation function to each tool
|
||||
return filteredTools.map(tool => ({
|
||||
...tool,
|
||||
navigate: () => handleToolSelect(tool.name)
|
||||
navigate: () => handleToolSelect(tool.id)
|
||||
}));
|
||||
}, [selectedToolKey, handleToolSelect]);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,121 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
|
||||
// Request queue to handle concurrent thumbnail requests
|
||||
interface ThumbnailRequest {
|
||||
pageId: string;
|
||||
file: File;
|
||||
pageNumber: number;
|
||||
resolve: (thumbnail: string | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
// Global request queue (shared across all hook instances)
|
||||
const requestQueue: ThumbnailRequest[] = [];
|
||||
let isProcessingQueue = false;
|
||||
let batchTimer: number | null = null;
|
||||
|
||||
// Track active thumbnail requests to prevent duplicates across components
|
||||
const activeRequests = new Map<string, Promise<string | null>>();
|
||||
|
||||
// Batch processing configuration
|
||||
const BATCH_SIZE = 20; // Process thumbnails in batches of 20 for better UI responsiveness
|
||||
const BATCH_DELAY = 100; // Wait 100ms to collect requests before processing
|
||||
const PRIORITY_BATCH_DELAY = 50; // Faster processing for the first batch (visible pages)
|
||||
|
||||
// Process the queue in batches for better performance
|
||||
async function processRequestQueue() {
|
||||
if (isProcessingQueue || requestQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingQueue = true;
|
||||
|
||||
try {
|
||||
while (requestQueue.length > 0) {
|
||||
// Sort queue by page number to prioritize visible pages first
|
||||
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
|
||||
// Take a batch of requests (same file only for efficiency)
|
||||
const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
|
||||
const batch = requestQueue.splice(0, batchSize);
|
||||
|
||||
// Group by file to process efficiently
|
||||
const fileGroups = new Map<File, ThumbnailRequest[]>();
|
||||
|
||||
// First, resolve any cached thumbnails immediately
|
||||
const uncachedRequests: ThumbnailRequest[] = [];
|
||||
|
||||
for (const request of batch) {
|
||||
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
|
||||
if (cached) {
|
||||
request.resolve(cached);
|
||||
} else {
|
||||
uncachedRequests.push(request);
|
||||
|
||||
if (!fileGroups.has(request.file)) {
|
||||
fileGroups.set(request.file, []);
|
||||
}
|
||||
fileGroups.get(request.file)!.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each file group with batch thumbnail generation
|
||||
for (const [file, requests] of fileGroups) {
|
||||
if (requests.length === 0) continue;
|
||||
|
||||
try {
|
||||
const pageNumbers = requests.map(req => req.pageNumber);
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||
|
||||
// Use file name as fileId for PDF document caching
|
||||
const fileId = file.name + '_' + file.size + '_' + file.lastModified;
|
||||
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
arrayBuffer,
|
||||
pageNumbers,
|
||||
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
|
||||
(progress) => {
|
||||
// Optional: Could emit progress events here for UI feedback
|
||||
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
|
||||
}
|
||||
);
|
||||
|
||||
// Match results back to requests and resolve
|
||||
for (const request of requests) {
|
||||
const result = results.find(r => r.pageNumber === request.pageNumber);
|
||||
|
||||
if (result && result.success && result.thumbnail) {
|
||||
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
|
||||
request.resolve(result.thumbnail);
|
||||
} else {
|
||||
console.warn(`No result for page ${request.pageNumber}`);
|
||||
request.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
|
||||
// Reject all requests in this batch
|
||||
requests.forEach(request => request.reject(error as Error));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isProcessingQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for tools that want to use thumbnail generation
|
||||
* Tools can choose whether to include visual features
|
||||
*/
|
||||
export function useThumbnailGeneration() {
|
||||
const generateThumbnails = useCallback(async (
|
||||
fileId: string,
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: {
|
||||
@ -18,6 +127,7 @@ export function useThumbnailGeneration() {
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: any[] }) => void
|
||||
) => {
|
||||
return thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
pdfArrayBuffer,
|
||||
pageNumbers,
|
||||
options,
|
||||
@ -42,15 +152,88 @@ export function useThumbnailGeneration() {
|
||||
}, []);
|
||||
|
||||
const destroyThumbnails = useCallback(() => {
|
||||
// Clear any pending batch timer
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
batchTimer = null;
|
||||
}
|
||||
|
||||
// Clear the queue and active requests
|
||||
requestQueue.length = 0;
|
||||
activeRequests.clear();
|
||||
isProcessingQueue = false;
|
||||
|
||||
thumbnailGenerationService.destroy();
|
||||
}, []);
|
||||
|
||||
const clearPDFCacheForFile = useCallback((fileId: string) => {
|
||||
thumbnailGenerationService.clearPDFCacheForFile(fileId);
|
||||
}, []);
|
||||
|
||||
const requestThumbnail = useCallback(async (
|
||||
pageId: string,
|
||||
file: File,
|
||||
pageNumber: number
|
||||
): Promise<string | null> => {
|
||||
// Check cache first for immediate return
|
||||
const cached = thumbnailGenerationService.getThumbnailFromCache(pageId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if this request is already being processed globally
|
||||
const activeRequest = activeRequests.get(pageId);
|
||||
if (activeRequest) {
|
||||
return activeRequest;
|
||||
}
|
||||
|
||||
// Create new request promise and track it globally
|
||||
const requestPromise = new Promise<string | null>((resolve, reject) => {
|
||||
requestQueue.push({
|
||||
pageId,
|
||||
file,
|
||||
pageNumber,
|
||||
resolve: (result: string | null) => {
|
||||
activeRequests.delete(pageId);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
activeRequests.delete(pageId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Schedule batch processing with a small delay to collect more requests
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
}
|
||||
|
||||
// Use shorter delay for the first batch (pages 1-50) to show visible content faster
|
||||
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
|
||||
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
|
||||
|
||||
batchTimer = window.setTimeout(() => {
|
||||
processRequestQueue().catch(error => {
|
||||
console.error('Error processing thumbnail request queue:', error);
|
||||
});
|
||||
batchTimer = null;
|
||||
}, delay);
|
||||
});
|
||||
|
||||
// Track this request to prevent duplicates
|
||||
activeRequests.set(pageId, requestPromise);
|
||||
|
||||
return requestPromise;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
generateThumbnails,
|
||||
addThumbnailToCache,
|
||||
getThumbnailFromCache,
|
||||
getCacheStats,
|
||||
stopGeneration,
|
||||
destroyThumbnails
|
||||
destroyThumbnails,
|
||||
clearPDFCacheForFile,
|
||||
requestThumbnail
|
||||
};
|
||||
}
|
@ -17,7 +17,7 @@ interface ToolManagementResult {
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string /* FIX ME: Should be ToolId */ | null>(null);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
@ -48,8 +48,8 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: t(baseTool.name),
|
||||
description: t(baseTool.description)
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -1,65 +1,87 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SUBCATEGORY_ORDER, ToolCategory, ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SubcategoryIdMap = {
|
||||
[subcategoryId in SubcategoryId]: Array<{ id: string /* FIX ME: Should be ToolId */; tool: ToolRegistryEntry }>;
|
||||
}
|
||||
|
||||
type GroupedTools = {
|
||||
[category: string]: {
|
||||
[subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>;
|
||||
};
|
||||
[categoryId in ToolCategoryId]: SubcategoryIdMap;
|
||||
};
|
||||
|
||||
export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) {
|
||||
export interface SubcategoryGroup {
|
||||
subcategoryId: SubcategoryId;
|
||||
tools: {
|
||||
id: string /* FIX ME: Should be ToolId */;
|
||||
tool: ToolRegistryEntry;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ToolSectionKey = 'quick' | 'all';
|
||||
|
||||
export interface ToolSection {
|
||||
key: ToolSectionKey;
|
||||
title: string;
|
||||
subcategories: SubcategoryGroup[];
|
||||
};
|
||||
|
||||
export function useToolSections(filteredTools: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry][]) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
const grouped: GroupedTools = {};
|
||||
const grouped = {} as GroupedTools;
|
||||
filteredTools.forEach(([id, tool]) => {
|
||||
const category = tool.category;
|
||||
const subcategory = tool.subcategory;
|
||||
if (!grouped[category]) grouped[category] = {};
|
||||
if (!grouped[category][subcategory]) grouped[category][subcategory] = [];
|
||||
grouped[category][subcategory].push({ id, tool });
|
||||
const categoryId = tool.categoryId;
|
||||
const subcategoryId = tool.subcategoryId;
|
||||
if (!grouped[categoryId]) grouped[categoryId] = {} as SubcategoryIdMap;
|
||||
if (!grouped[categoryId][subcategoryId]) grouped[categoryId][subcategoryId] = [];
|
||||
grouped[categoryId][subcategoryId].push({ id, tool });
|
||||
});
|
||||
return grouped;
|
||||
}, [filteredTools]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const getOrderIndex = (name: string) => {
|
||||
const idx = SUBCATEGORY_ORDER.indexOf(name as any);
|
||||
const sections: ToolSection[] = useMemo(() => {
|
||||
const getOrderIndex = (id: SubcategoryId) => {
|
||||
const idx = SUBCATEGORY_ORDER.indexOf(id);
|
||||
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
|
||||
};
|
||||
|
||||
const quick: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
|
||||
const all: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
|
||||
const quick = {} as SubcategoryIdMap;
|
||||
const all = {} as SubcategoryIdMap;
|
||||
|
||||
Object.entries(groupedTools).forEach(([origCat, subs]) => {
|
||||
const upperCat = origCat.toUpperCase();
|
||||
Object.entries(groupedTools).forEach(([c, subs]) => {
|
||||
const categoryId = c as ToolCategoryId;
|
||||
|
||||
Object.entries(subs).forEach(([sub, tools]) => {
|
||||
if (!all[sub]) all[sub] = [];
|
||||
all[sub].push(...tools);
|
||||
Object.entries(subs).forEach(([s, tools]) => {
|
||||
const subcategoryId = s as SubcategoryId;
|
||||
if (!all[subcategoryId]) all[subcategoryId] = [];
|
||||
all[subcategoryId].push(...tools);
|
||||
});
|
||||
|
||||
if (upperCat === ToolCategory.RECOMMENDED_TOOLS.toUpperCase()) {
|
||||
Object.entries(subs).forEach(([sub, tools]) => {
|
||||
if (!quick[sub]) quick[sub] = [];
|
||||
quick[sub].push(...tools);
|
||||
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {
|
||||
Object.entries(subs).forEach(([s, tools]) => {
|
||||
const subcategoryId = s as SubcategoryId;
|
||||
if (!quick[subcategoryId]) quick[subcategoryId] = [];
|
||||
quick[subcategoryId].push(...tools);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
|
||||
const sortSubs = (obj: SubcategoryIdMap) =>
|
||||
Object.entries(obj)
|
||||
.sort(([a], [b]) => {
|
||||
const ai = getOrderIndex(a);
|
||||
const bi = getOrderIndex(b);
|
||||
const aId = a as SubcategoryId;
|
||||
const bId = b as SubcategoryId;
|
||||
const ai = getOrderIndex(aId);
|
||||
const bi = getOrderIndex(bId);
|
||||
if (ai !== bi) return ai - bi;
|
||||
return a.localeCompare(b);
|
||||
return aId.localeCompare(bId);
|
||||
})
|
||||
.map(([subcategory, tools]) => ({ subcategory, tools }));
|
||||
.map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup));
|
||||
|
||||
const built = [
|
||||
const built: ToolSection[] = [
|
||||
{ key: 'quick', title: t('toolPicker.quickAccess', 'QUICK ACCESS'), subcategories: sortSubs(quick) },
|
||||
{ key: 'all', title: t('toolPicker.allTools', 'ALL TOOLS'), subcategories: sortSubs(all) }
|
||||
];
|
||||
@ -67,19 +89,20 @@ export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) {
|
||||
return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0));
|
||||
}, [groupedTools]);
|
||||
|
||||
const searchGroups = useMemo(() => {
|
||||
const subMap: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
|
||||
const seen = new Set<string>();
|
||||
const searchGroups: SubcategoryGroup[] = useMemo(() => {
|
||||
const subMap = {} as SubcategoryIdMap;
|
||||
const seen = new Set<string /* FIX ME: Should be ToolId */>();
|
||||
filteredTools.forEach(([id, tool]) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const sub = tool.subcategory;
|
||||
const toolId = id as string /* FIX ME: Should be ToolId */;
|
||||
if (seen.has(toolId)) return;
|
||||
seen.add(toolId);
|
||||
const sub = tool.subcategoryId;
|
||||
if (!subMap[sub]) subMap[sub] = [];
|
||||
subMap[sub].push({ id, tool });
|
||||
subMap[sub].push({ id: toolId, tool });
|
||||
});
|
||||
return Object.entries(subMap)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([subcategory, tools]) => ({ subcategory, tools }));
|
||||
.map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup));
|
||||
}, [filteredTools]);
|
||||
|
||||
return { sections, searchGroups };
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user