mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-19 09:59:22 +00:00
Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into feature/V2/AddStamp
This commit is contained in:
commit
0578782b12
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@mantine/core": "^8.0.1",
|
||||
"@mantine/dates": "^8.0.1",
|
||||
"@mantine/dropzone": "^8.0.1",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
@ -1653,6 +1654,22 @@
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/dates": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz",
|
||||
"integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "8.0.1",
|
||||
"@mantine/hooks": "8.0.1",
|
||||
"dayjs": ">=1.0.0",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/dropzone": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
|
||||
@ -4367,6 +4384,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.18",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
@ -10,6 +10,7 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@mantine/core": "^8.0.1",
|
||||
"@mantine/dates": "^8.0.1",
|
||||
"@mantine/dropzone": "^8.0.1",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
|
@ -797,9 +797,27 @@
|
||||
"rotate": {
|
||||
"tags": "server side",
|
||||
"title": "Rotate PDF",
|
||||
"header": "Rotate PDF",
|
||||
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
|
||||
"submit": "Rotate"
|
||||
"submit": "Apply Rotation",
|
||||
"error": {
|
||||
"failed": "An error occurred while rotating the PDF."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Rotation Preview"
|
||||
},
|
||||
"rotateLeft": "Rotate Anticlockwise",
|
||||
"rotateRight": "Rotate Clockwise",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Rotate Settings Overview"
|
||||
},
|
||||
"description": {
|
||||
"text": "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation."
|
||||
},
|
||||
"controls": {
|
||||
"title": "Controls",
|
||||
"text": "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees."
|
||||
}
|
||||
}
|
||||
},
|
||||
"convert": {
|
||||
"title": "Convert",
|
||||
@ -1125,15 +1143,46 @@
|
||||
"removePages": {
|
||||
"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",
|
||||
"pageNumbers": {
|
||||
"label": "Pages to Remove",
|
||||
"placeholder": "e.g., 1,3,5-8,10",
|
||||
"error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)"
|
||||
},
|
||||
"filenamePrefix": "pages_removed",
|
||||
"files": {
|
||||
"placeholder": "Select a PDF file in the main view to get started"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Page Selection"
|
||||
"title": "Settings"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Remove Pages Settings"
|
||||
},
|
||||
"pageNumbers": {
|
||||
"title": "Page Selection",
|
||||
"text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.",
|
||||
"bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)",
|
||||
"bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)",
|
||||
"bullet3": "Mathematical: 2n+1 (removes odd pages)",
|
||||
"bullet4": "Open ranges: 5- (removes from page 5 to end)"
|
||||
},
|
||||
"examples": {
|
||||
"title": "Common Examples",
|
||||
"text": "Here are some common page selection patterns:",
|
||||
"bullet1": "Remove first page: 1",
|
||||
"bullet2": "Remove last 3 pages: -3",
|
||||
"bullet3": "Remove every other page: 2n",
|
||||
"bullet4": "Remove specific scattered pages: 1,5,10,15"
|
||||
},
|
||||
"safety": {
|
||||
"title": "Safety Tips",
|
||||
"text": "Important considerations when removing pages:",
|
||||
"bullet1": "Always preview your selection before processing",
|
||||
"bullet2": "Keep a backup of your original file",
|
||||
"bullet3": "Page numbers start from 1, not 0",
|
||||
"bullet4": "Invalid page numbers will be ignored"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred whilst removing pages."
|
||||
@ -1145,9 +1194,7 @@
|
||||
},
|
||||
"pageSelection": {
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Page Selection Guide"
|
||||
},
|
||||
"header": { "title": "Page Selection Guide" },
|
||||
"basic": {
|
||||
"title": "Basic Usage",
|
||||
"text": "Select specific pages from your PDF document using simple syntax.",
|
||||
@ -1164,7 +1211,74 @@
|
||||
"bullet1": "Page numbers start from 1 (not 0)",
|
||||
"bullet2": "Spaces are automatically removed",
|
||||
"bullet3": "Invalid expressions are ignored"
|
||||
},
|
||||
"syntax": {
|
||||
"title": "Syntax Basics",
|
||||
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
|
||||
"bullets": {
|
||||
"numbers": "Numbers/ranges: 5, 10-20",
|
||||
"keywords": "Keywords: odd, even",
|
||||
"progressions": "Progressions: 3n, 4n+1"
|
||||
}
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operators",
|
||||
"text": "AND has higher precedence than comma. NOT applies within the document range.",
|
||||
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
|
||||
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
|
||||
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
},
|
||||
"examples": { "title": "Examples" }
|
||||
}
|
||||
},
|
||||
"bulkSelection": {
|
||||
"header": { "title": "Page Selection Guide" },
|
||||
"syntax": {
|
||||
"title": "Syntax Basics",
|
||||
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
|
||||
"bullets": {
|
||||
"numbers": "Numbers/ranges: 5, 10-20",
|
||||
"keywords": "Keywords: odd, even",
|
||||
"progressions": "Progressions: 3n, 4n+1"
|
||||
}
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operators",
|
||||
"text": "AND has higher precedence than comma. NOT applies within the document range.",
|
||||
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
|
||||
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
|
||||
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
},
|
||||
"examples": {
|
||||
"title": "Examples",
|
||||
"first50": "First 50",
|
||||
"last50": "Last 50",
|
||||
"every3rd": "Every 3rd",
|
||||
"oddWithinExcluding": "Odd within 1-20 excluding 5-7",
|
||||
"combineSets": "Combine sets"
|
||||
},
|
||||
"firstNPages": {
|
||||
"title": "First N Pages",
|
||||
"placeholder": "Number of pages"
|
||||
},
|
||||
"lastNPages": {
|
||||
"title": "Last N Pages",
|
||||
"placeholder": "Number of pages"
|
||||
},
|
||||
"everyNthPage": {
|
||||
"title": "Every Nth Page",
|
||||
"placeholder": "Step size"
|
||||
},
|
||||
"range": {
|
||||
"title": "Range",
|
||||
"fromPlaceholder": "From",
|
||||
"toPlaceholder": "To"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Keywords"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced"
|
||||
}
|
||||
},
|
||||
"compressPdfs": {
|
||||
@ -1189,24 +1303,127 @@
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "Title,author,date,creation,time,publisher,producer,stats",
|
||||
"title": "Change Metadata",
|
||||
"header": "Change Metadata",
|
||||
"selectText": {
|
||||
"1": "Please edit the variables you wish to change",
|
||||
"2": "Delete all metadata",
|
||||
"3": "Show Custom Metadata:",
|
||||
"4": "Other Metadata:",
|
||||
"5": "Add Custom Metadata Entry"
|
||||
"submit": "Change",
|
||||
"filenamePrefix": "metadata",
|
||||
"settings": {
|
||||
"title": "Metadata Settings"
|
||||
},
|
||||
"author": "Author:",
|
||||
"creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):",
|
||||
"creator": "Creator:",
|
||||
"keywords": "Keywords:",
|
||||
"modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):",
|
||||
"producer": "Producer:",
|
||||
"subject": "Subject:",
|
||||
"trapped": "Trapped:",
|
||||
"submit": "Change"
|
||||
"standardFields": {
|
||||
"title": "Standard Fields"
|
||||
},
|
||||
"deleteAll": {
|
||||
"label": "Remove Existing Metadata",
|
||||
"checkbox": "Delete all metadata"
|
||||
},
|
||||
"title": {
|
||||
"label": "Title",
|
||||
"placeholder": "Document title"
|
||||
},
|
||||
"author": {
|
||||
"label": "Author",
|
||||
"placeholder": "Document author"
|
||||
},
|
||||
"subject": {
|
||||
"label": "Subject",
|
||||
"placeholder": "Document subject"
|
||||
},
|
||||
"keywords": {
|
||||
"label": "Keywords",
|
||||
"placeholder": "Document keywords"
|
||||
},
|
||||
"creator": {
|
||||
"label": "Creator",
|
||||
"placeholder": "Document creator"
|
||||
},
|
||||
"producer": {
|
||||
"label": "Producer",
|
||||
"placeholder": "Document producer"
|
||||
},
|
||||
"dates": {
|
||||
"title": "Date Fields"
|
||||
},
|
||||
"creationDate": {
|
||||
"label": "Creation Date",
|
||||
"placeholder": "Creation date"
|
||||
},
|
||||
"modificationDate": {
|
||||
"label": "Modification Date",
|
||||
"placeholder": "Modification date"
|
||||
},
|
||||
"trapped": {
|
||||
"label": "Trapped Status",
|
||||
"unknown": "Unknown",
|
||||
"true": "True",
|
||||
"false": "False"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Options"
|
||||
},
|
||||
"customFields": {
|
||||
"title": "Custom Metadata",
|
||||
"description": "Add custom metadata fields to the document",
|
||||
"add": "Add Field",
|
||||
"key": "Key",
|
||||
"keyPlaceholder": "Custom key",
|
||||
"value": "Value",
|
||||
"valuePlaceholder": "Custom value",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"results": {
|
||||
"title": "Updated PDFs"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while changing the PDF metadata."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "PDF Metadata Overview"
|
||||
},
|
||||
"standardFields": {
|
||||
"title": "Standard Fields",
|
||||
"text": "Common PDF metadata fields that describe the document.",
|
||||
"bullet1": "Title: Document name or heading",
|
||||
"bullet2": "Author: Person who created the document",
|
||||
"bullet3": "Subject: Brief description of content",
|
||||
"bullet4": "Keywords: Search terms for the document",
|
||||
"bullet5": "Creator/Producer: Software used to create the PDF"
|
||||
},
|
||||
"dates": {
|
||||
"title": "Date Fields",
|
||||
"text": "When the document was created and modified.",
|
||||
"bullet1": "Creation Date: When original document was made",
|
||||
"bullet2": "Modification Date: When last changed"
|
||||
},
|
||||
"options": {
|
||||
"title": "Additional Options",
|
||||
"text": "Custom fields and privacy controls.",
|
||||
"bullet1": "Custom Metadata: Add your own key-value pairs",
|
||||
"bullet2": "Trapped Status: High-quality printing setting",
|
||||
"bullet3": "Delete All: Remove all metadata for privacy"
|
||||
},
|
||||
"deleteAll": {
|
||||
"title": "Remove Existing Metadata",
|
||||
"text": "Complete metadata deletion to ensure privacy."
|
||||
},
|
||||
"customFields": {
|
||||
"title": "Custom Metadata",
|
||||
"text": "Add your own custom key-value metadata pairs.",
|
||||
"bullet1": "Add any custom fields relevant to your document",
|
||||
"bullet2": "Examples: Department, Project, Version, Status",
|
||||
"bullet3": "Both key and value are required for each entry"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Options",
|
||||
"trapped": {
|
||||
"title": "Trapped Status",
|
||||
"description": "Indicates if document is prepared for high-quality printing.",
|
||||
"bullet1": "True: Document has been trapped for printing",
|
||||
"bullet2": "False: Document has not been trapped",
|
||||
"bullet3": "Unknown: Trapped status is not specified"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileToPDF": {
|
||||
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",
|
||||
@ -1492,11 +1709,46 @@
|
||||
"tags": "cleanup,streamline,non-content,organize",
|
||||
"title": "Remove Blanks",
|
||||
"header": "Remove Blank Pages",
|
||||
"threshold": "Pixel Whiteness Threshold:",
|
||||
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.",
|
||||
"whitePercent": "White Percent (%):",
|
||||
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed",
|
||||
"submit": "Remove Blanks"
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Pixel Whiteness Threshold"
|
||||
},
|
||||
"whitePercent": {
|
||||
"label": "White Percentage Threshold",
|
||||
"unit": "%"
|
||||
},
|
||||
"includeBlankPages": {
|
||||
"label": "Include detected blank pages"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Remove Blank Pages Settings"
|
||||
},
|
||||
"threshold": {
|
||||
"title": "Pixel Whiteness Threshold",
|
||||
"text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.",
|
||||
"bullet1": "0 = Pure black (most restrictive)",
|
||||
"bullet2": "128 = Medium grey",
|
||||
"bullet3": "255 = Pure white (least restrictive)"
|
||||
},
|
||||
"whitePercent": {
|
||||
"title": "White Percentage Threshold",
|
||||
"text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.",
|
||||
"bullet1": "Lower values (e.g., 80%) = More pages removed",
|
||||
"bullet2": "Higher values (e.g., 95%) = Only very blank pages removed",
|
||||
"bullet3": "Use higher values for documents with light backgrounds"
|
||||
},
|
||||
"includeBlankPages": {
|
||||
"title": "Include Detected Blank Pages",
|
||||
"text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.",
|
||||
"bullet1": "Useful for reviewing what was removed",
|
||||
"bullet2": "Helps verify the detection accuracy",
|
||||
"bullet3": "Can be disabled to reduce output file size"
|
||||
}
|
||||
},
|
||||
"submit": "Remove blank pages"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
|
@ -745,15 +745,46 @@
|
||||
"removePages": {
|
||||
"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",
|
||||
"pageNumbers": {
|
||||
"label": "Pages to Remove",
|
||||
"placeholder": "e.g., 1,3,5-8,10",
|
||||
"error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)"
|
||||
},
|
||||
"filenamePrefix": "pages_removed",
|
||||
"files": {
|
||||
"placeholder": "Select a PDF file in the main view to get started"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Page Selection"
|
||||
"title": "Settings"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Remove Pages Settings"
|
||||
},
|
||||
"pageNumbers": {
|
||||
"title": "Page Selection",
|
||||
"text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.",
|
||||
"bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)",
|
||||
"bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)",
|
||||
"bullet3": "Mathematical: 2n+1 (removes odd pages)",
|
||||
"bullet4": "Open ranges: 5- (removes from page 5 to end)"
|
||||
},
|
||||
"examples": {
|
||||
"title": "Common Examples",
|
||||
"text": "Here are some common page selection patterns:",
|
||||
"bullet1": "Remove first page: 1",
|
||||
"bullet2": "Remove last 3 pages: -3",
|
||||
"bullet3": "Remove every other page: 2n",
|
||||
"bullet4": "Remove specific scattered pages: 1,5,10,15"
|
||||
},
|
||||
"safety": {
|
||||
"title": "Safety Tips",
|
||||
"text": "Important considerations when removing pages:",
|
||||
"bullet1": "Always preview your selection before processing",
|
||||
"bullet2": "Keep a backup of your original file",
|
||||
"bullet3": "Page numbers start from 1, not 0",
|
||||
"bullet4": "Invalid page numbers will be ignored"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing pages."
|
||||
@ -807,7 +838,74 @@
|
||||
"bullet1": "Page numbers start from 1 (not 0)",
|
||||
"bullet2": "Spaces are automatically removed",
|
||||
"bullet3": "Invalid expressions are ignored"
|
||||
},
|
||||
"syntax": {
|
||||
"title": "Syntax Basics",
|
||||
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
|
||||
"bullets": {
|
||||
"numbers": "Numbers/ranges: 5, 10-20",
|
||||
"keywords": "Keywords: odd, even",
|
||||
"progressions": "Progressions: 3n, 4n+1"
|
||||
}
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operators",
|
||||
"text": "AND has higher precedence than comma. NOT applies within the document range.",
|
||||
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
|
||||
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
|
||||
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
},
|
||||
"examples": { "title": "Examples" }
|
||||
}
|
||||
},
|
||||
"bulkSelection": {
|
||||
"header": { "title": "Page Selection Guide" },
|
||||
"syntax": {
|
||||
"title": "Syntax Basics",
|
||||
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
|
||||
"bullets": {
|
||||
"numbers": "Numbers/ranges: 5, 10-20",
|
||||
"keywords": "Keywords: odd, even",
|
||||
"progressions": "Progressions: 3n, 4n+1"
|
||||
}
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operators",
|
||||
"text": "AND has higher precedence than comma. NOT applies within the document range.",
|
||||
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
|
||||
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
|
||||
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
},
|
||||
"examples": {
|
||||
"title": "Examples",
|
||||
"first50": "First 50",
|
||||
"last50": "Last 50",
|
||||
"every3rd": "Every 3rd",
|
||||
"oddWithinExcluding": "Odd within 1-20 excluding 5-7",
|
||||
"combineSets": "Combine sets"
|
||||
},
|
||||
"firstNPages": {
|
||||
"title": "First N Pages",
|
||||
"placeholder": "Number of pages"
|
||||
},
|
||||
"lastNPages": {
|
||||
"title": "Last N Pages",
|
||||
"placeholder": "Number of pages"
|
||||
},
|
||||
"everyNthPage": {
|
||||
"title": "Every Nth Page",
|
||||
"placeholder": "Step size"
|
||||
},
|
||||
"range": {
|
||||
"title": "Range",
|
||||
"fromPlaceholder": "From",
|
||||
"toPlaceholder": "To"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Keywords"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced"
|
||||
}
|
||||
},
|
||||
"compressPdfs": {
|
||||
@ -1013,11 +1111,46 @@
|
||||
"tags": "cleanup,streamline,non-content,organize",
|
||||
"title": "Remove Blanks",
|
||||
"header": "Remove Blank Pages",
|
||||
"threshold": "Pixel Whiteness Threshold:",
|
||||
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.",
|
||||
"whitePercent": "White Percent (%):",
|
||||
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed",
|
||||
"submit": "Remove Blanks"
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Pixel Whiteness Threshold"
|
||||
},
|
||||
"whitePercent": {
|
||||
"label": "White Percentage Threshold",
|
||||
"unit": "%"
|
||||
},
|
||||
"includeBlankPages": {
|
||||
"label": "Include detected blank pages"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Remove Blank Pages Settings"
|
||||
},
|
||||
"threshold": {
|
||||
"title": "Pixel Whiteness Threshold",
|
||||
"text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.",
|
||||
"bullet1": "0 = Pure black (most restrictive)",
|
||||
"bullet2": "128 = Medium gray",
|
||||
"bullet3": "255 = Pure white (least restrictive)"
|
||||
},
|
||||
"whitePercent": {
|
||||
"title": "White Percentage Threshold",
|
||||
"text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.",
|
||||
"bullet1": "Lower values (e.g., 80%) = More pages removed",
|
||||
"bullet2": "Higher values (e.g., 95%) = Only very blank pages removed",
|
||||
"bullet3": "Use higher values for documents with light backgrounds"
|
||||
},
|
||||
"includeBlankPages": {
|
||||
"title": "Include Detected Blank Pages",
|
||||
"text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.",
|
||||
"bullet1": "Useful for reviewing what was removed",
|
||||
"bullet2": "Helps verify the detection accuracy",
|
||||
"bullet3": "Can be disabled to reduce output file size"
|
||||
}
|
||||
},
|
||||
"submit": "Remove blank pages"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
|
@ -1,12 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Group, TextInput, Button, Text } from '@mantine/core';
|
||||
import { useState, useEffect } from 'react';
|
||||
import classes from './bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import { parseSelectionWithDiagnostics } from '../../utils/bulkselection/parseSelection';
|
||||
import PageSelectionInput from './bulkSelectionPanel/PageSelectionInput';
|
||||
import SelectedPagesDisplay from './bulkSelectionPanel/SelectedPagesDisplay';
|
||||
import AdvancedSelectionPanel from './bulkSelectionPanel/AdvancedSelectionPanel';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
onUpdatePagesFromCSV: () => void;
|
||||
onUpdatePagesFromCSV: (override?: string) => void;
|
||||
}
|
||||
|
||||
const BulkSelectionPanel = ({
|
||||
@ -16,31 +20,56 @@ const BulkSelectionPanel = ({
|
||||
displayDocument,
|
||||
onUpdatePagesFromCSV,
|
||||
}: BulkSelectionPanelProps) => {
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
|
||||
const maxPages = displayDocument?.pages?.length ?? 0;
|
||||
|
||||
|
||||
// Validate input syntax and show lightweight feedback
|
||||
useEffect(() => {
|
||||
const text = (csvInput || '').trim();
|
||||
if (!text) {
|
||||
setSyntaxError(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
|
||||
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
|
||||
} catch {
|
||||
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
|
||||
}
|
||||
}, [csvInput, maxPages]);
|
||||
|
||||
const handleClear = () => {
|
||||
setCsvInput('');
|
||||
onUpdatePagesFromCSV('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
onChange={(e) => setCsvInput(e.target.value)}
|
||||
placeholder="1,3,5-10"
|
||||
label="Page Selection"
|
||||
onBlur={onUpdatePagesFromCSV}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
|
||||
style={{ flex: 1 }}
|
||||
<div className={classes.panelContainer}>
|
||||
<PageSelectionInput
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
|
||||
onClear={handleClear}
|
||||
advancedOpened={advancedOpened}
|
||||
onToggleAdvanced={setAdvancedOpened}
|
||||
/>
|
||||
<Button onClick={onUpdatePagesFromCSV} mt="xl">
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
{selectedPageIds.length > 0 && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(n => n > 0).join(', ') : ''})
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
<SelectedPagesDisplay
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
syntaxError={syntaxError}
|
||||
/>
|
||||
|
||||
<AdvancedSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
|
||||
maxPages={maxPages}
|
||||
advancedOpened={advancedOpened}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -171,7 +171,8 @@ const PageEditor = ({
|
||||
},
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => getPageNumbersFromIds(selectedPageIds)
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}
|
||||
@ -228,7 +229,8 @@ const PageEditor = ({
|
||||
},
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => selectedPageNumbers
|
||||
() => selectedPageNumbers,
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
|
||||
@ -246,7 +248,8 @@ const PageEditor = ({
|
||||
},
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => getPageNumbersFromIds(selectedPageIds)
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
|
@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classes from './BulkSelectionPanel.module.css';
|
||||
import {
|
||||
appendExpression,
|
||||
insertOperatorSmart,
|
||||
firstNExpression,
|
||||
lastNExpression,
|
||||
everyNthExpression,
|
||||
rangeExpression,
|
||||
LogicalOperator,
|
||||
} from './BulkSelection';
|
||||
import SelectPages from './SelectPages';
|
||||
import OperatorsSection from './OperatorsSection';
|
||||
|
||||
interface AdvancedSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
onUpdatePagesFromCSV: (override?: string) => void;
|
||||
maxPages: number;
|
||||
advancedOpened?: boolean;
|
||||
}
|
||||
|
||||
const AdvancedSelectionPanel = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
onUpdatePagesFromCSV,
|
||||
maxPages,
|
||||
advancedOpened,
|
||||
}: AdvancedSelectionPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [rangeEnd, setRangeEnd] = useState<number | ''>('');
|
||||
|
||||
const handleRangeEndChange = (val: string | number) => {
|
||||
const next = typeof val === 'number' ? val : '';
|
||||
setRangeEnd(next);
|
||||
};
|
||||
|
||||
// Named validation functions
|
||||
const validatePositiveNumber = (value: number): string | null => {
|
||||
return value <= 0 ? 'Enter a positive number' : null;
|
||||
};
|
||||
|
||||
const validateRangeStart = (start: number): string | null => {
|
||||
if (start <= 0) return 'Values must be positive';
|
||||
if (typeof rangeEnd === 'number' && start > rangeEnd) {
|
||||
return 'From must be less than or equal to To';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Named callback functions
|
||||
const applyExpression = (expr: string) => {
|
||||
const nextInput = appendExpression(csvInput, expr);
|
||||
setCsvInput(nextInput);
|
||||
onUpdatePagesFromCSV(nextInput);
|
||||
};
|
||||
|
||||
const insertOperator = (op: LogicalOperator) => {
|
||||
const next = insertOperatorSmart(csvInput, op);
|
||||
setCsvInput(next);
|
||||
// Trigger visual selection update for 'even' and 'odd' operators
|
||||
if (op === 'even' || op === 'odd') {
|
||||
onUpdatePagesFromCSV(next);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstNApply = (value: number) => {
|
||||
const expr = firstNExpression(value, maxPages);
|
||||
if (expr) applyExpression(expr);
|
||||
};
|
||||
|
||||
const handleLastNApply = (value: number) => {
|
||||
const expr = lastNExpression(value, maxPages);
|
||||
if (expr) applyExpression(expr);
|
||||
};
|
||||
|
||||
const handleEveryNthApply = (value: number) => {
|
||||
const expr = everyNthExpression(value);
|
||||
if (expr) applyExpression(expr);
|
||||
};
|
||||
|
||||
const handleRangeApply = (start: number) => {
|
||||
if (typeof rangeEnd !== 'number') return;
|
||||
const expr = rangeExpression(start, rangeEnd, maxPages);
|
||||
if (expr) applyExpression(expr);
|
||||
setRangeEnd('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Advanced section */}
|
||||
{advancedOpened && (
|
||||
<div className={classes.advancedSection}>
|
||||
<div className={classes.advancedContent}>
|
||||
{/* Cards row */}
|
||||
<Flex direction="row" mb="xs" wrap="wrap">
|
||||
<SelectPages
|
||||
title={t('bulkSelection.firstNPages.title', 'First N Pages')}
|
||||
placeholder={t('bulkSelection.firstNPages.placeholder', 'Number of pages')}
|
||||
onApply={handleFirstNApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validatePositiveNumber}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t('bulkSelection.range.title', 'Range')}
|
||||
placeholder={t('bulkSelection.range.fromPlaceholder', 'From')}
|
||||
onApply={handleRangeApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validateRangeStart}
|
||||
isRange={true}
|
||||
rangeEndValue={rangeEnd}
|
||||
onRangeEndChange={handleRangeEndChange}
|
||||
rangeEndPlaceholder={t('bulkSelection.range.toPlaceholder', 'To')}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t('bulkSelection.lastNPages.title', 'Last N Pages')}
|
||||
placeholder={t('bulkSelection.lastNPages.placeholder', 'Number of pages')}
|
||||
onApply={handleLastNApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validatePositiveNumber}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t('bulkSelection.everyNthPage.title', 'Every Nth Page')}
|
||||
placeholder={t('bulkSelection.everyNthPage.placeholder', 'Step size')}
|
||||
onApply={handleEveryNthApply}
|
||||
maxPages={maxPages}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Operators row at bottom */}
|
||||
<OperatorsSection
|
||||
csvInput={csvInput}
|
||||
onInsertOperator={insertOperator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedSelectionPanel;
|
@ -0,0 +1,136 @@
|
||||
// Pure helper utilities for the BulkSelectionPanel UI
|
||||
|
||||
export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';
|
||||
|
||||
// Returns a new CSV expression with expr appended.
|
||||
// If current ends with an operator token, expr is appended directly.
|
||||
// Otherwise, it is joined with " or ".
|
||||
export function appendExpression(currentInput: string, expr: string): string {
|
||||
const current = (currentInput || '').trim();
|
||||
if (!current) return expr;
|
||||
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
|
||||
// Add space if operator doesn't already have one
|
||||
if (endsWithOperator) {
|
||||
const needsSpace = !current.endsWith(' ');
|
||||
return `${current}${needsSpace ? ' ' : ''}${expr}`;
|
||||
}
|
||||
return `${current} or ${expr}`;
|
||||
}
|
||||
|
||||
// Smartly inserts/normalizes a logical operator at the end of the current input.
|
||||
// Produces a trailing space to allow the next token to be typed naturally.
|
||||
export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string {
|
||||
const text = (currentInput || '').trim();
|
||||
// Handle 'even' and 'odd' as page selection expressions, not logical operators
|
||||
if (op === 'even' || op === 'odd') {
|
||||
if (text.length === 0) return `${op} `;
|
||||
// If current input ends with a logical operator, append the page selection with proper spacing
|
||||
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
|
||||
if (endsWithOperator) {
|
||||
// Add space if the operator doesn't already have one
|
||||
const needsSpace = !text.endsWith(' ');
|
||||
return `${text}${needsSpace ? ' ' : ''}${op} `;
|
||||
}
|
||||
return `${text} or ${op} `;
|
||||
}
|
||||
|
||||
if (text.length === 0) return `${op} `;
|
||||
|
||||
// Extract up to the last two operator tokens (words or symbols) from the end
|
||||
const tokens: string[] = [];
|
||||
let rest = text;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
|
||||
if (!m || m.index === undefined) break;
|
||||
const raw = m[1].toLowerCase();
|
||||
const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw;
|
||||
tokens.unshift(word);
|
||||
rest = rest.slice(0, m.index).trimEnd();
|
||||
}
|
||||
|
||||
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
|
||||
const click = op; // desired operator
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return emit(text, click);
|
||||
}
|
||||
|
||||
// Normalize to allowed set
|
||||
const phrase = tokens.join(' ');
|
||||
const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']);
|
||||
|
||||
// Helpers for transitions from a single trailing token
|
||||
const fromSingle = (t: string): string => {
|
||||
if (t === 'and') {
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or'
|
||||
return 'and not';
|
||||
}
|
||||
if (t === 'or') {
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or';
|
||||
return 'or not';
|
||||
}
|
||||
// t === 'not'
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or';
|
||||
return 'not';
|
||||
};
|
||||
|
||||
// From combined phrase
|
||||
const fromCombo = (p: string): string => {
|
||||
if (p === 'and not') {
|
||||
if (click === 'not') return 'and not';
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or'
|
||||
return 'and not';
|
||||
}
|
||||
if (p === 'or not') {
|
||||
if (click === 'not') return 'or not';
|
||||
if (click === 'or') return 'or';
|
||||
if (click === 'and') return 'and'; // 'or not and' is invalid, so just use 'and'
|
||||
return 'or not';
|
||||
}
|
||||
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
|
||||
return click;
|
||||
};
|
||||
|
||||
const base = rest.trim();
|
||||
const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
|
||||
if (!allowed.has(nextPhrase)) {
|
||||
return emit(base, click);
|
||||
}
|
||||
return emit(base, nextPhrase);
|
||||
}
|
||||
|
||||
// Expression builders for Advanced actions
|
||||
export function firstNExpression(n: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
|
||||
return `1-${end}`;
|
||||
}
|
||||
|
||||
export function lastNExpression(n: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const count = Math.max(1, Math.floor(n));
|
||||
const start = Math.max(1, maxPages - count + 1);
|
||||
if (maxPages <= 0) return null;
|
||||
return `${start}-${maxPages}`;
|
||||
}
|
||||
|
||||
export function everyNthExpression(n: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return `${Math.max(1, Math.floor(n))}n`;
|
||||
}
|
||||
|
||||
export function rangeExpression(start: number, end: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
let s = Math.floor(start);
|
||||
let e = Math.floor(end);
|
||||
if (s > e) [s, e] = [e, s];
|
||||
s = Math.max(1, s);
|
||||
e = maxPages > 0 ? Math.min(maxPages, e) : e;
|
||||
return `${s}-${e}`;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,295 @@
|
||||
.panelGroup {
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
min-width: 24rem;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dropdownContainer {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.menuDropdown {
|
||||
min-width: 22.5rem;
|
||||
}
|
||||
|
||||
.dropdownContent {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.leftCol {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 8rem - 0.75rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rightCol {
|
||||
width: 8rem;
|
||||
border-left: 0.0625rem solid var(--border-default);
|
||||
padding-left: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.operatorGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.operatorChip {
|
||||
width: 100%;
|
||||
border-radius: 1.25rem;
|
||||
border: 0.0625rem solid var(--bulk-card-border);
|
||||
background-color: var(--bulk-card-bg);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.operatorChip:hover:not(:disabled) {
|
||||
border-color: var(--bulk-card-hover-border);
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-0.0625rem);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.operatorChip:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.operatorChip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .operatorChip {
|
||||
background-color: var(--bulk-card-bg);
|
||||
border-color: var(--bulk-card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .operatorChip:hover:not(:disabled) {
|
||||
background-color: var(--hover-bg);
|
||||
border-color: var(--bulk-card-hover-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 0.0625rem solid var(--border-default);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menuItemRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Icon-based chevrons */
|
||||
.chevronIcon {
|
||||
transition: transform 150ms ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.chevronUp {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
.inlineRow {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.inlineRowCompact {
|
||||
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.menuItemCloseHover {
|
||||
background-color: var(--text-brand-accent);
|
||||
opacity: 0.1;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .menuItemCloseHover {
|
||||
background-color: var(--text-brand-accent);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.selectedList {
|
||||
max-height: 8rem;
|
||||
overflow: auto;
|
||||
background-color: var(--bg-raised);
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 24rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selectedText {
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.advancedSection {
|
||||
margin-top: 0.5rem;
|
||||
min-width: 24rem;
|
||||
}
|
||||
|
||||
.advancedHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 0.0625rem solid var(--border-default);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.advancedContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.advancedItem {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.advancedItem:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .advancedItem:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.advancedCard {
|
||||
background-color: var(--bulk-card-bg);
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .advancedCard {
|
||||
background-color: var(--bulk-card-bg);
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fullWidthInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.applyButton {
|
||||
min-width: 4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Style inputs and buttons within advanced cards to match bg-raised */
|
||||
.advancedCard :global(.mantine-NumberInput-input) {
|
||||
background-color: var(--bg-raised) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.advancedCard :global(.mantine-Button-root) {
|
||||
background-color: var(--bg-raised) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.advancedCard :global(.mantine-Button-root:hover) {
|
||||
background-color: var(--hover-bg) !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
}
|
||||
|
||||
/* Error helper text above the input */
|
||||
.errorText {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
/* Dark-mode adjustments */
|
||||
:global([data-mantine-color-scheme='dark']) .selectedList {
|
||||
background-color: var(--bg-raised);
|
||||
}
|
||||
|
||||
/* Small screens: allow the section to shrink instead of enforcing a large min width */
|
||||
@media (max-width: 480px) {
|
||||
.panelGroup,
|
||||
.selectedList,
|
||||
.advancedSection,
|
||||
.panelContainer {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Outermost panel container scrolling */
|
||||
.panelContainer {
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
background-color: var(--bulk-panel-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Override Mantine Popover dropdown background */
|
||||
:global(.mantine-Popover-dropdown) {
|
||||
background-color: var(--bulk-panel-bg) !important;
|
||||
border-color: var(--bulk-card-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Override Mantine Switch outline */
|
||||
.advancedSwitch :global(.mantine-Switch-input) {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.advancedSwitch :global(.mantine-Switch-input:focus) {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
@ -0,0 +1,74 @@
|
||||
import { Button, Text, Group, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classes from './BulkSelectionPanel.module.css';
|
||||
import { LogicalOperator } from './BulkSelection';
|
||||
|
||||
interface OperatorsSectionProps {
|
||||
csvInput: string;
|
||||
onInsertOperator: (op: LogicalOperator) => void;
|
||||
}
|
||||
|
||||
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">{t('bulkSelection.keywords.title', 'Keywords')}:</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('and')}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Combine selections (both conditions must be true)"
|
||||
>
|
||||
<Text size="xs" fw={500}>and</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('or')}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Add to selection (either condition can be true)"
|
||||
>
|
||||
<Text size="xs" fw={500}>or</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('not')}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Exclude from selection"
|
||||
>
|
||||
<Text size="xs" fw={500}>not</Text>
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('even')}
|
||||
title="Select all even-numbered pages (2, 4, 6, 8...)"
|
||||
>
|
||||
<Text size="xs" fw={500}>even</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('odd')}
|
||||
title="Select all odd-numbered pages (1, 3, 5, 7...)"
|
||||
>
|
||||
<Text size="xs" fw={500}>odd</Text>
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperatorsSection;
|
@ -0,0 +1,94 @@
|
||||
import { TextInput, Button, Text, Flex, Switch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { usePageSelectionTips } from '../../tooltips/usePageSelectionTips';
|
||||
import classes from './BulkSelectionPanel.module.css';
|
||||
|
||||
interface PageSelectionInputProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
onUpdatePagesFromCSV: (override?: string) => void;
|
||||
onClear: () => void;
|
||||
advancedOpened?: boolean;
|
||||
onToggleAdvanced?: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const PageSelectionInput = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
onUpdatePagesFromCSV,
|
||||
onClear,
|
||||
advancedOpened,
|
||||
onToggleAdvanced,
|
||||
}: PageSelectionInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const pageSelectionTips = usePageSelectionTips();
|
||||
|
||||
return (
|
||||
<div className={classes.panelGroup}>
|
||||
{/* Header row with tooltip/title and advanced toggle */}
|
||||
<Flex justify="space-between" align="center" mb="sm">
|
||||
<Tooltip
|
||||
position="left"
|
||||
offset={20}
|
||||
header={pageSelectionTips.header}
|
||||
portalTarget={document.body}
|
||||
pinOnClick={true}
|
||||
containerStyle={{ marginTop: "1rem"}}
|
||||
tips={pageSelectionTips.tips}
|
||||
>
|
||||
<Flex onClick={(e) => e.stopPropagation()} align="center" gap="xs">
|
||||
<LocalIcon icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" style={{ color: 'var(--text-instruction)' }} />
|
||||
<Text>Page Selection</Text>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
{typeof advancedOpened === 'boolean' && (
|
||||
<Flex align="center" gap="xs">
|
||||
<Text size="sm" c="var(--text-secondary)">{t('bulkSelection.advanced.title', 'Advanced')}</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={!!advancedOpened}
|
||||
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
|
||||
title={t('bulkSelection.advanced.title', 'Advanced')}
|
||||
className={classes.advancedSwitch}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setCsvInput(next);
|
||||
onUpdatePagesFromCSV(next);
|
||||
}}
|
||||
placeholder="1,3,5-10"
|
||||
rightSection={
|
||||
csvInput && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
minWidth: 'auto',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
|
||||
className={classes.textInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSelectionInput;
|
@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Text, NumberInput, Group } from '@mantine/core';
|
||||
import classes from './BulkSelectionPanel.module.css';
|
||||
|
||||
interface SelectPagesProps {
|
||||
title: string;
|
||||
placeholder: string;
|
||||
onApply: (value: number) => void;
|
||||
maxPages: number;
|
||||
validationFn?: (value: number) => string | null;
|
||||
isRange?: boolean;
|
||||
rangeEndValue?: number | '';
|
||||
onRangeEndChange?: (value: string | number) => void;
|
||||
rangeEndPlaceholder?: string;
|
||||
}
|
||||
|
||||
const SelectPages = ({
|
||||
title,
|
||||
placeholder,
|
||||
onApply,
|
||||
validationFn,
|
||||
isRange = false,
|
||||
rangeEndValue,
|
||||
onRangeEndChange,
|
||||
rangeEndPlaceholder,
|
||||
}: SelectPagesProps) => {
|
||||
const [value, setValue] = useState<number | ''>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = (val: string | number) => {
|
||||
const next = typeof val === 'number' ? val : '';
|
||||
setValue(next);
|
||||
|
||||
if (validationFn && typeof next === 'number') {
|
||||
setError(validationFn(next));
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (value === '' || typeof value !== 'number') return;
|
||||
onApply(value);
|
||||
setValue('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const isDisabled = Boolean(error) || value === '';
|
||||
|
||||
return (
|
||||
<div className={classes.advancedCard}>
|
||||
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">{title}</Text>
|
||||
{error && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{error}</Text>)}
|
||||
<div className={classes.inputGroup}>
|
||||
<Group gap="sm" align="flex-end" wrap="nowrap">
|
||||
{isRange ? (
|
||||
<>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
min={1}
|
||||
placeholder={placeholder}
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={rangeEndValue}
|
||||
onChange={onRangeEndChange}
|
||||
min={1}
|
||||
placeholder={rangeEndPlaceholder}
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
min={1}
|
||||
placeholder={placeholder}
|
||||
className={classes.fullWidthInput}
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className={classes.applyButton}
|
||||
onClick={handleApply}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectPages;
|
@ -0,0 +1,35 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import classes from './BulkSelectionPanel.module.css';
|
||||
|
||||
interface SelectedPagesDisplayProps {
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
syntaxError: string | null;
|
||||
}
|
||||
|
||||
const SelectedPagesDisplay = ({
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
syntaxError,
|
||||
}: SelectedPagesDisplayProps) => {
|
||||
if (selectedPageIds.length === 0 && !syntaxError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.selectedList}>
|
||||
{syntaxError ? (
|
||||
<Text size="xs" className={classes.errorText}>{syntaxError}</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" className={classes.selectedText}>
|
||||
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(n => n > 0).join(', ') : ''})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedPagesDisplay;
|
@ -59,6 +59,7 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
private originalSelectedPages: number[] = [];
|
||||
private hasExecuted: boolean = false;
|
||||
private pageIdsToDelete: string[] = [];
|
||||
private onAllPagesDeleted?: () => void;
|
||||
|
||||
constructor(
|
||||
private pagesToDelete: number[],
|
||||
@ -67,9 +68,11 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void,
|
||||
private getSelectedPages: () => number[]
|
||||
private getSelectedPages: () => number[],
|
||||
onAllPagesDeleted?: () => void
|
||||
) {
|
||||
super();
|
||||
this.onAllPagesDeleted = onAllPagesDeleted;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@ -99,7 +102,13 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
!this.pageIdsToDelete.includes(page.id)
|
||||
);
|
||||
|
||||
if (remainingPages.length === 0) return; // Safety check
|
||||
if (remainingPages.length === 0) {
|
||||
// If all pages would be deleted, clear selection/splits and close PDF
|
||||
this.setSelectedPages([]);
|
||||
this.setSplitPositions(new Set());
|
||||
this.onAllPagesDeleted?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Renumber remaining pages
|
||||
remainingPages.forEach((page, index) => {
|
||||
|
@ -11,6 +11,7 @@ import LanguageSelector from '../shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
|
||||
export default function RightRail() {
|
||||
const { t } = useTranslation();
|
||||
@ -111,50 +112,13 @@ export default function RightRail() {
|
||||
setSelectedFiles([]);
|
||||
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
// CSV parsing functions for page selection
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
const pageNumbers: number[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i > 0) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(range);
|
||||
if (pageNum > 0) {
|
||||
pageNumbers.push(pageNum);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pageNumbers;
|
||||
}, []);
|
||||
|
||||
const updatePagesFromCSV = useCallback(() => {
|
||||
const rawPages = parseCSVInput(csvInput);
|
||||
// Use PageEditor's total pages for validation
|
||||
const updatePagesFromCSV = useCallback((override?: string) => {
|
||||
const maxPages = pageEditorFunctions?.totalPages || 0;
|
||||
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
|
||||
// Use PageEditor's function to set selected pages
|
||||
const normalized = parseSelection(override ?? csvInput, maxPages);
|
||||
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
|
||||
}, [csvInput, parseCSVInput, pageEditorFunctions]);
|
||||
}, [csvInput, pageEditorFunctions]);
|
||||
|
||||
// Sync csvInput with PageEditor's selected pages
|
||||
useEffect(() => {
|
||||
const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument
|
||||
? pageEditorFunctions.selectedPageIds.map(id => {
|
||||
const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0).sort((a, b) => a - b)
|
||||
: [];
|
||||
const newCsvInput = sortedPageNumbers.join(', ');
|
||||
setCsvInput(newCsvInput);
|
||||
}, [pageEditorFunctions?.selectedPageIds]);
|
||||
// Do not overwrite user's expression input when selection changes.
|
||||
|
||||
// Clear CSV input when files change (use stable signature to avoid ref churn)
|
||||
useEffect(() => {
|
||||
@ -260,7 +224,7 @@ export default function RightRail() {
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: 280 }}>
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
|
||||
import { addEventListenerWithCleanup } from '../../utils/genericUtils';
|
||||
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
|
||||
import { TooltipTip } from '../../types/tips';
|
||||
import { TooltipContent } from './tooltip/TooltipContent';
|
||||
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||
import styles from './tooltip/Tooltip.module.css'
|
||||
import styles from './tooltip/Tooltip.module.css';
|
||||
|
||||
export interface TooltipProps {
|
||||
sidebarTooltip?: boolean;
|
||||
@ -21,12 +21,12 @@ export interface TooltipProps {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
arrow?: boolean;
|
||||
portalTarget?: HTMLElement;
|
||||
header?: {
|
||||
title: string;
|
||||
logo?: React.ReactNode;
|
||||
};
|
||||
header?: { title: string; logo?: React.ReactNode };
|
||||
delay?: number;
|
||||
containerStyle?: React.CSSProperties;
|
||||
pinOnClick?: boolean;
|
||||
/** If true, clicking outside also closes when not pinned (default true) */
|
||||
closeOnOutside?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
@ -45,56 +45,40 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
header,
|
||||
delay = 0,
|
||||
containerStyle = {},
|
||||
pinOnClick = false,
|
||||
closeOnOutside = true,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = () => {
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clickPendingRef = useRef(false);
|
||||
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
openTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get sidebar context for tooltip positioning
|
||||
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
|
||||
|
||||
// Always use controlled mode - if no controlled props provided, use internal state
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const open = isControlled ? !!controlledOpen : internalOpen;
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
clearTimers();
|
||||
if (isControlled) {
|
||||
onOpenChange?.(newOpen);
|
||||
} else {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
const setOpen = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (newOpen === open) return; // avoid churn
|
||||
if (isControlled) onOpenChange?.(newOpen);
|
||||
else setInternalOpen(newOpen);
|
||||
if (!newOpen) setIsPinned(false);
|
||||
},
|
||||
[isControlled, onOpenChange, open]
|
||||
);
|
||||
|
||||
// Reset pin state when closing
|
||||
if (!newOpen) {
|
||||
setIsPinned(false);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleTooltipClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsPinned(true);
|
||||
};
|
||||
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
// If tooltip is pinned and we click outside of it, unpin it
|
||||
if (isPinned && isClickOutside(e, tooltipRef.current)) {
|
||||
setIsPinned(false);
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use the positioning hook
|
||||
const { coords, positionReady } = useTooltipPosition({
|
||||
open,
|
||||
sidebarTooltip,
|
||||
@ -103,56 +87,209 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
triggerRef,
|
||||
tooltipRef,
|
||||
sidebarRefs: sidebarContext?.sidebarRefs,
|
||||
sidebarState: sidebarContext?.sidebarState
|
||||
sidebarState: sidebarContext?.sidebarState,
|
||||
});
|
||||
|
||||
// Add document click listener for unpinning
|
||||
useEffect(() => {
|
||||
// Close on outside click: pinned → close; not pinned → optionally close
|
||||
const handleDocumentClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const tEl = tooltipRef.current;
|
||||
const trg = triggerRef.current;
|
||||
const target = e.target as Node | null;
|
||||
const insideTooltip = tEl && target && tEl.contains(target);
|
||||
const insideTrigger = trg && target && trg.contains(target);
|
||||
|
||||
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
||||
if (isPinned) {
|
||||
if (!insideTooltip && !insideTrigger) {
|
||||
setIsPinned(false);
|
||||
setOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not pinned and configured to close on outside
|
||||
if (closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[isPinned, closeOnOutside, setOpen]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Attach global click when open (so hover tooltips can also close on outside if desired)
|
||||
if (open || isPinned) {
|
||||
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
|
||||
}
|
||||
}, [isPinned]);
|
||||
}, [open, isPinned, handleDocumentClick]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => () => clearTimers(), [clearTimers]);
|
||||
|
||||
|
||||
const getArrowClass = () => {
|
||||
// No arrow for sidebar tooltips
|
||||
const arrowClass = useMemo(() => {
|
||||
if (sidebarTooltip) return null;
|
||||
const map: Record<NonNullable<TooltipProps['position']>, string> = {
|
||||
top: 'tooltip-arrow-bottom',
|
||||
bottom: 'tooltip-arrow-top',
|
||||
left: 'tooltip-arrow-left',
|
||||
right: 'tooltip-arrow-right',
|
||||
};
|
||||
return map[position] || map.right;
|
||||
}, [position, sidebarTooltip]);
|
||||
|
||||
switch (position) {
|
||||
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
|
||||
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
|
||||
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
||||
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
||||
default: return "tooltip-arrow tooltip-arrow-right";
|
||||
const getArrowStyleClass = useCallback(
|
||||
(key: string) =>
|
||||
styles[key as keyof typeof styles] ||
|
||||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
|
||||
'',
|
||||
[]
|
||||
);
|
||||
|
||||
// === Trigger handlers ===
|
||||
const openWithDelay = useCallback(() => {
|
||||
clearTimers();
|
||||
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
|
||||
}, [clearTimers, setOpen, delay]);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isPinned) openWithDelay();
|
||||
(children.props as any)?.onPointerEnter?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const getArrowStyleClass = (arrowClass: string) => {
|
||||
const styleKey = arrowClass.split(' ')[1];
|
||||
// Handle both kebab-case and camelCase CSS module exports
|
||||
return styles[styleKey as keyof typeof styles] ||
|
||||
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] ||
|
||||
'';
|
||||
};
|
||||
// Ignore transient leave between mousedown and click
|
||||
if (clickPendingRef.current) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
},
|
||||
[clearTimers, isPinned, setOpen, children.props]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clickPendingRef.current = true;
|
||||
(children.props as any)?.onMouseDown?.(e);
|
||||
},
|
||||
[children.props]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// allow microtask turn so click can see this false
|
||||
queueMicrotask(() => (clickPendingRef.current = false));
|
||||
(children.props as any)?.onMouseUp?.(e);
|
||||
},
|
||||
[children.props]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clearTimers();
|
||||
if (pinOnClick) {
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (!open) setOpen(true);
|
||||
setIsPinned(true);
|
||||
clickPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
clickPendingRef.current = false;
|
||||
(children.props as any)?.onClick?.(e);
|
||||
},
|
||||
[clearTimers, pinOnClick, open, setOpen, children.props]
|
||||
);
|
||||
|
||||
// Keyboard / focus accessibility
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
if (!isPinned) openWithDelay();
|
||||
(children.props as any)?.onFocus?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
return;
|
||||
}
|
||||
if (!isPinned) setOpen(false);
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
},
|
||||
[isPinned, setOpen, children.props]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
||||
const handleTooltipPointerEnter = useCallback(() => {
|
||||
clearTimers();
|
||||
}, [clearTimers]);
|
||||
|
||||
const handleTooltipPointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
);
|
||||
|
||||
// Enhance child with handlers and ref
|
||||
const childWithHandlers = React.cloneElement(children as any, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node || null;
|
||||
const originalRef = (children as any).ref;
|
||||
if (typeof originalRef === 'function') originalRef(node);
|
||||
else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node;
|
||||
},
|
||||
'aria-describedby': open ? tooltipIdRef.current : undefined,
|
||||
onPointerEnter: handlePointerEnter,
|
||||
onPointerLeave: handlePointerLeave,
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
onClick: handleClick,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
});
|
||||
|
||||
// Always mount when open so we can measure; hide until positioned to avoid flash
|
||||
const shouldShowTooltip = open;
|
||||
|
||||
const tooltipElement = shouldShowTooltip ? (
|
||||
<div
|
||||
id={tooltipIdRef.current}
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
tabIndex={-1}
|
||||
onPointerEnter={handleTooltipPointerEnter}
|
||||
onPointerLeave={handleTooltipPointerLeave}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
|
||||
minWidth: minWidth,
|
||||
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
|
||||
minWidth,
|
||||
zIndex: 9999,
|
||||
visibility: positionReady ? 'visible' : 'hidden',
|
||||
opacity: positionReady ? 1 : 0,
|
||||
@ -160,7 +297,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
...containerStyle,
|
||||
}}
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={handleTooltipClick}
|
||||
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
||||
>
|
||||
{isPinned && (
|
||||
<button
|
||||
@ -168,94 +305,45 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPinned(false);
|
||||
handleOpenChange(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Close tooltip"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
)}
|
||||
{arrow && getArrowClass() && (
|
||||
{arrow && !sidebarTooltip && (
|
||||
<div
|
||||
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
|
||||
style={coords.arrowOffset !== null ? {
|
||||
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
|
||||
} : undefined}
|
||||
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
|
||||
style={
|
||||
coords.arrowOffset !== null
|
||||
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{header && (
|
||||
<div className={styles['tooltip-header']}>
|
||||
<div className={styles['tooltip-logo']}>
|
||||
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
|
||||
{header.logo || (
|
||||
<img
|
||||
src="/logo-tooltip.svg"
|
||||
alt="Stirling PDF"
|
||||
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<TooltipContent
|
||||
content={content}
|
||||
tips={tips}
|
||||
/>
|
||||
<TooltipContent content={content} tips={tips} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
clearTimers();
|
||||
if (!isPinned) {
|
||||
const effectiveDelay = Math.max(0, delay || 0);
|
||||
openTimeoutRef.current = setTimeout(() => {
|
||||
handleOpenChange(true);
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
(children.props as any)?.onMouseEnter?.(e);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
clearTimers();
|
||||
openTimeoutRef.current = null;
|
||||
|
||||
if (!isPinned) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
(children.props as any)?.onMouseLeave?.(e);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Toggle pin state on click
|
||||
if (open) {
|
||||
setIsPinned(!isPinned);
|
||||
} else {
|
||||
clearTimers();
|
||||
handleOpenChange(true);
|
||||
setIsPinned(true);
|
||||
}
|
||||
|
||||
(children.props as any)?.onClick?.(e);
|
||||
};
|
||||
|
||||
// Take the child element and add tooltip behavior to it
|
||||
const childWithTooltipHandlers = React.cloneElement(children as any, {
|
||||
// Keep track of the element for positioning
|
||||
ref: (node: HTMLElement) => {
|
||||
triggerRef.current = node;
|
||||
// Don't break if the child already has a ref
|
||||
const originalRef = (children as any).ref;
|
||||
if (typeof originalRef === 'function') {
|
||||
originalRef(node);
|
||||
} else if (originalRef && typeof originalRef === 'object') {
|
||||
originalRef.current = node;
|
||||
}
|
||||
},
|
||||
// Add mouse events to show/hide tooltip
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
onClick: handleClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{childWithTooltipHandlers}
|
||||
{childWithHandlers}
|
||||
{portalTarget && document.body.contains(portalTarget)
|
||||
? tooltipElement && createPortal(tooltipElement, portalTarget)
|
||||
: tooltipElement}
|
||||
|
@ -6,7 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
|
||||
const viewOptionStyle = {
|
||||
display: 'inline-flex',
|
||||
@ -18,7 +18,7 @@ const viewOptionStyle = {
|
||||
}
|
||||
|
||||
|
||||
// Build view options showing text only for current view; others icon-only with tooltip
|
||||
// Build view options showing text always
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
|
||||
{
|
||||
label: (
|
||||
@ -35,7 +35,6 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Tooltip content="Page Editor" position="bottom" arrow={true}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
@ -43,16 +42,17 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
) : (
|
||||
switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
value: "pageEditor",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Tooltip content="Active Files" position="bottom" arrow={true}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
@ -60,10 +60,12 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
value: "fileEditor",
|
||||
},
|
||||
|
@ -1,177 +1,155 @@
|
||||
# Tooltip Component
|
||||
|
||||
A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click.
|
||||
A flexible, accessible tooltip component supporting regular positioning and special sidebar positioning, with optional click‑to‑pin behavior. By default, it opens on hover/focus and can be pinned on click when `pinOnClick` is enabled.
|
||||
|
||||
## Features
|
||||
---
|
||||
|
||||
- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds
|
||||
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
|
||||
- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality
|
||||
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX
|
||||
- 🌙 **Theme Support**: Built-in dark mode and theme variable support
|
||||
- ⚡ **Performance**: Memoized calculations and efficient event handling
|
||||
- 📜 **Scrollable**: Content area scrolls when content exceeds max height
|
||||
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
|
||||
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
|
||||
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
|
||||
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
|
||||
## Highlights
|
||||
|
||||
* 🎯 **Smart Positioning**: Keeps tooltips within the viewport and aligns the arrow dynamically.
|
||||
* 📱 **Sidebar Aware**: Purpose‑built logic for sidebar/navigation contexts.
|
||||
* ♿ **Accessible**: Keyboard and screen‑reader friendly (`role="tooltip"`, `aria-describedby`, Escape to close, focus/blur support).
|
||||
* 🎨 **Customizable**: Arrows, headers, rich JSX content, and structured tips.
|
||||
* 🌙 **Themeable**: Uses CSS variables; supports dark mode out of the box.
|
||||
* ⚡ **Efficient**: Memoized calculations and stable callbacks to minimize re‑renders.
|
||||
* 📜 **Scrollable Content**: When content exceeds max height.
|
||||
* 📌 **Click‑to‑Pin**: (Optional) Pin open; close via outside click or close button.
|
||||
* 🔗 **Link‑Safe**: Fully clickable links in descriptions, bullets, and custom content.
|
||||
* 🖱️ **Pointer‑Friendly**: Uses pointer events (works with mouse/pen/touch hover where applicable).
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
### Default Behavior (Controlled)
|
||||
- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering
|
||||
- **Click**: Click the trigger to pin the tooltip open
|
||||
- **Click tooltip**: Pins the tooltip to keep it open
|
||||
- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned)
|
||||
- **Click outside**: Unpins and closes the tooltip
|
||||
- **Visual indicator**: Pinned tooltips have a blue border and close button
|
||||
### Default
|
||||
|
||||
### Manual Control (Optional)
|
||||
- Use `open` and `onOpenChange` props for complete external control
|
||||
- Useful for complex state management or custom interaction patterns
|
||||
* **Hover/Focus**: Opens on pointer **enter** or when the trigger receives **focus** (respects optional `delay`).
|
||||
* **Leave/Blur**: Closes on pointer **leave** (from trigger *and* tooltip) or when the trigger/tooltip **blurs** to the page—unless pinned.
|
||||
* **Inside Tooltip**: Moving from trigger → tooltip keeps it open; moving out of both closes it (unless pinned).
|
||||
* **Escape**: Press **Esc** to close.
|
||||
|
||||
### Click‑to‑Pin (optional)
|
||||
|
||||
* Enable with `pinOnClick`.
|
||||
* **Click trigger** (or tooltip) to pin open.
|
||||
* **Click outside** **both** trigger and tooltip to close when pinned.
|
||||
* Use the close button (X) to unpin and close.
|
||||
|
||||
> **Note**: Outside‑click closing when **not** pinned is configurable via `closeOnOutside` (default `true`).
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```tsx
|
||||
import { Tooltip } from '@/components/shared';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { Tooltip } from '@/components/shared';
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<Tooltip content="This is a helpful tooltip">
|
||||
<button>Hover me</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip |
|
||||
| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body |
|
||||
| `children` | `ReactElement` | **required** | Element that triggers the tooltip |
|
||||
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic |
|
||||
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) |
|
||||
| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip |
|
||||
| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip |
|
||||
| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) |
|
||||
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control |
|
||||
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
|
||||
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
|
||||
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
|
||||
| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately |
|
||||
|
||||
### TooltipTip Interface
|
||||
|
||||
```typescript
|
||||
interface TooltipTip {
|
||||
title?: string; // Optional pill label
|
||||
description?: string; // Optional description text (supports HTML including <a> tags)
|
||||
bullets?: string[]; // Optional bullet points (supports HTML including <a> tags)
|
||||
body?: React.ReactNode; // Optional custom JSX for this tip
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Default Behavior (Recommended)
|
||||
With structured tips and a header:
|
||||
|
||||
```tsx
|
||||
// Simple tooltip with hover and click-to-pin
|
||||
<Tooltip content="This tooltip appears on hover and pins on click">
|
||||
<button>Hover me</button>
|
||||
</Tooltip>
|
||||
|
||||
// Structured content with tips
|
||||
<Tooltip
|
||||
tips={[
|
||||
{
|
||||
title: "OCR Mode",
|
||||
description: "Choose how to process text in your documents.",
|
||||
tips={[{
|
||||
title: 'OCR Mode',
|
||||
description: 'Choose how to process text in your documents.',
|
||||
bullets: [
|
||||
"<strong>Auto</strong> skips pages that already contain text.",
|
||||
"<strong>Force</strong> re-processes every page.",
|
||||
"<strong>Strict</strong> stops if text is found.",
|
||||
"<a href='https://docs.example.com' target='_blank'>Learn more</a>"
|
||||
]
|
||||
}
|
||||
]}
|
||||
header={{
|
||||
title: "Basic Settings Overview",
|
||||
logo: <img src="/logo.svg" alt="Logo" />
|
||||
}}
|
||||
'<strong>Auto</strong> skips pages that already contain text.',
|
||||
'<strong>Force</strong> re-processes every page.',
|
||||
'<strong>Strict</strong> stops if text is found.',
|
||||
"<a href='https://docs.example.com' target='_blank' rel='noreferrer'>Learn more</a>",
|
||||
],
|
||||
}]}
|
||||
header={{ title: 'Basic Settings Overview', logo: <img src="/logo.svg" alt="Logo" /> }}
|
||||
>
|
||||
<button>Settings</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `<Tooltip />` Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `children` | `ReactElement` | **required** | The trigger element. Receives ARIA and event handlers. |
|
||||
| `content` | `ReactNode` | `undefined` | Custom JSX content rendered below any `tips`. |
|
||||
| `tips` | `TooltipTip[]` | `undefined` | Structured content (title, description, bullets, optional body). |
|
||||
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic (no arrow in sidebar mode). |
|
||||
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Preferred placement (ignored if `sidebarTooltip` is `true`). |
|
||||
| `offset` | `number` | `8` | Gap (px) between trigger and tooltip. |
|
||||
| `maxWidth` | `number \| string` | `undefined` | Max width. If omitted and `sidebarTooltip` is true, defaults visually to \~`25rem`. |
|
||||
| `minWidth` | `number \| string` | `undefined` | Min width. |
|
||||
| `open` | `boolean` | `undefined` | Controlled open state. If provided, the component is controlled. |
|
||||
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback when open state would change. |
|
||||
| `arrow` | `boolean` | `false` | Shows a directional arrow (suppressed in sidebar mode). |
|
||||
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into. |
|
||||
| `header` | `{ title: string; logo?: ReactNode }` | `undefined` | Optional header with title and logo. |
|
||||
| `delay` | `number` | `0` | Hover/focus open delay in ms. |
|
||||
| `containerStyle` | `React.CSSProperties` | `{}` | Inline style overrides for the tooltip container. |
|
||||
| `pinOnClick` | `boolean` | `false` | Clicking the trigger pins the tooltip open. |
|
||||
| `closeOnOutside` | `boolean` | `true` | When not pinned, clicking outside closes the tooltip. Always closes when pinned and clicking outside both trigger & tooltip. |
|
||||
|
||||
### `TooltipTip`
|
||||
|
||||
```ts
|
||||
export interface TooltipTip {
|
||||
title?: string; // Optional pill label
|
||||
description?: string; // HTML allowed (e.g., <a>)
|
||||
bullets?: string[]; // HTML allowed in each string
|
||||
body?: React.ReactNode; // Optional custom JSX
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
* The tooltip container uses `role="tooltip"` and gets a stable `id`.
|
||||
* The trigger receives `aria-describedby` when the tooltip is open.
|
||||
* Opens on **focus** and closes on **blur** (unless pinned), supporting keyboard navigation.
|
||||
* **Escape** closes the tooltip.
|
||||
* Pointer events are mirrored with keyboard/focus for parity.
|
||||
|
||||
> Ensure custom triggers remain focusable (e.g., `button`, `a`, or add `tabIndex=0`).
|
||||
|
||||
---
|
||||
|
||||
## Interaction Details
|
||||
|
||||
* **Hover Timing**: Opening can be delayed via `delay`. Closing is immediate on pointer leave from both trigger and tooltip (unless pinned). Timers are cleared on state changes and unmounts.
|
||||
* **Outside Clicks**: When pinned, clicking outside **both** the trigger and tooltip closes it. When not pinned, outside clicks close it if `closeOnOutside` is `true`.
|
||||
* **Event Preservation**: Original child event handlers (`onClick`, `onPointerEnter`, etc.) are called after the tooltip augments them.
|
||||
* **Refs**: The trigger’s existing `ref` (function or object) is preserved.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### With Arrow
|
||||
|
||||
```tsx
|
||||
<Tooltip content="Arrow tooltip" arrow position="top">
|
||||
<button>Arrow tooltip</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Optional Hover Delay
|
||||
|
||||
```tsx
|
||||
// Show after a 1s hover
|
||||
<Tooltip content="Appears after a long hover" delay={1000} />
|
||||
|
||||
// Custom long-hover duration (2 seconds)
|
||||
<Tooltip content="Appears after 2s" delay={2000} />
|
||||
```
|
||||
|
||||
|
||||
### Custom JSX Content
|
||||
|
||||
```tsx
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
<h3>Custom Content</h3>
|
||||
<p>Any JSX you want here</p>
|
||||
<button>Action</button>
|
||||
<a href="https://example.com">External link</a>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button>Custom tooltip</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Mixed Content (Tips + Custom JSX)
|
||||
|
||||
```tsx
|
||||
<Tooltip
|
||||
tips={[
|
||||
{ title: "Section", description: "Description" }
|
||||
]}
|
||||
content={<div>Additional custom content below tips</div>}
|
||||
>
|
||||
<button>Mixed content</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Sidebar Tooltips
|
||||
|
||||
```tsx
|
||||
// For items in a sidebar/navigation
|
||||
<Tooltip
|
||||
content="This tooltip appears to the right of the sidebar"
|
||||
sidebarTooltip={true}
|
||||
>
|
||||
<div className="sidebar-item">
|
||||
📁 File Manager
|
||||
</div>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### With Arrows
|
||||
|
||||
```tsx
|
||||
<Tooltip
|
||||
content="Tooltip with arrow pointing to trigger"
|
||||
arrow={true}
|
||||
position="top"
|
||||
>
|
||||
<button>Arrow tooltip</button>
|
||||
<Tooltip content="Appears after 1s" delay={1000}>
|
||||
<button>Delayed</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
@ -180,63 +158,55 @@ interface TooltipTip {
|
||||
```tsx
|
||||
function ManualControlTooltip() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content="Fully controlled tooltip"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
Toggle tooltip
|
||||
</button>
|
||||
<Tooltip content="Fully controlled tooltip" open={open} onOpenChange={setOpen}>
|
||||
<button onClick={() => setOpen(!open)}>Toggle tooltip</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Click-to-Pin Interaction
|
||||
### Sidebar Tooltip
|
||||
|
||||
### How to Use (Default Behavior)
|
||||
1. **Hover** over the trigger element to show the tooltip
|
||||
2. **Click** the trigger element to pin the tooltip open
|
||||
3. **Click** the red X button in the top-right corner to close
|
||||
4. **Click** anywhere outside the tooltip to close
|
||||
5. **Click** the trigger again to toggle pin state
|
||||
```tsx
|
||||
<Tooltip content="Appears to the right of the sidebar" sidebarTooltip>
|
||||
<div className="sidebar-item">📁 File Manager</div>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Visual States
|
||||
- **Unpinned**: Normal tooltip appearance
|
||||
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
|
||||
### Mixed Content
|
||||
|
||||
## Link Support
|
||||
```tsx
|
||||
<Tooltip
|
||||
tips={[{ title: 'Section', description: 'Description' }]}
|
||||
content={<div>Additional custom content below tips</div>}
|
||||
>
|
||||
<button>Mixed content</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
The tooltip fully supports clickable links in all content areas:
|
||||
---
|
||||
|
||||
- **Descriptions**: Use `<a href="...">` in description strings
|
||||
- **Bullets**: Use `<a href="...">` in bullet point strings
|
||||
- **Body**: Use JSX `<a>` elements in the body ReactNode
|
||||
- **Content**: Use JSX `<a>` elements in custom content
|
||||
## Positioning Notes
|
||||
|
||||
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
|
||||
* Initial placement is derived from `position` (or sidebar rules when `sidebarTooltip` is true).
|
||||
* Tooltip is clamped within the viewport; the arrow is offset to remain visually aligned with the trigger.
|
||||
* Sidebar mode positions to the sidebar’s edge and clamps vertically. Arrows are disabled in sidebar mode.
|
||||
|
||||
## Positioning Logic
|
||||
---
|
||||
|
||||
### Regular Tooltips
|
||||
- Uses the `position` prop to determine initial placement
|
||||
- Automatically clamps to viewport boundaries
|
||||
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
|
||||
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
|
||||
## Caveats & Tips
|
||||
|
||||
## Timing Details
|
||||
* Ensure your container doesn’t block pointer events between trigger and tooltip.
|
||||
* When using `portalTarget`, confirm it’s attached to `document.body` before rendering.
|
||||
* For very dynamic layouts, call positioning after layout changes (the hook already listens to open/refs/viewport).
|
||||
|
||||
- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned).
|
||||
- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps.
|
||||
- Only one tooltip can be open at a time; hovering a new trigger closes others immediately.
|
||||
---
|
||||
|
||||
### Sidebar Tooltips
|
||||
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
|
||||
- Vertical positioning follows the trigger but clamps to viewport
|
||||
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position
|
||||
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed
|
||||
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`)
|
||||
- **No arrows** - sidebar tooltips don't show arrows
|
||||
## Changelog (since previous README)
|
||||
|
||||
* Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`).
|
||||
* Clarified outside‑click behavior for pinned vs unpinned.
|
||||
* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
|
||||
* Removed references to non‑existent props (e.g., `delayAppearance`).
|
||||
* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).
|
||||
|
@ -0,0 +1,99 @@
|
||||
import { Stack, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeMetadataParameters, createCustomMetadataFunctions } from "../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { useMetadataExtraction } from "../../../hooks/tools/changeMetadata/useMetadataExtraction";
|
||||
import DeleteAllStep from "./steps/DeleteAllStep";
|
||||
import StandardMetadataStep from "./steps/StandardMetadataStep";
|
||||
import DocumentDatesStep from "./steps/DocumentDatesStep";
|
||||
import AdvancedOptionsStep from "./steps/AdvancedOptionsStep";
|
||||
|
||||
interface ChangeMetadataSingleStepProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ChangeMetadataSingleStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ChangeMetadataSingleStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get custom metadata functions using the utility
|
||||
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(
|
||||
parameters,
|
||||
onParameterChange
|
||||
);
|
||||
|
||||
// Extract metadata from uploaded files
|
||||
const { isExtractingMetadata } = useMetadataExtraction({
|
||||
updateParameter: onParameterChange,
|
||||
});
|
||||
|
||||
const isDeleteAllEnabled = parameters.deleteAll;
|
||||
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Delete All */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('changeMetadata.deleteAll.label', 'Delete All Metadata')}
|
||||
</Text>
|
||||
<DeleteAllStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Standard Metadata Fields */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('changeMetadata.standardFields.title', 'Standard Metadata')}
|
||||
</Text>
|
||||
<StandardMetadataStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Document Dates */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('changeMetadata.dates.title', 'Document Dates')}
|
||||
</Text>
|
||||
<DocumentDatesStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('changeMetadata.advanced.title', 'Advanced Options')}
|
||||
</Text>
|
||||
<AdvancedOptionsStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
addCustomMetadata={addCustomMetadata}
|
||||
removeCustomMetadata={removeCustomMetadata}
|
||||
updateCustomMetadata={updateCustomMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeMetadataSingleStep;
|
@ -0,0 +1,60 @@
|
||||
import { Stack, Select, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { TrappedStatus } from "../../../../types/metadata";
|
||||
import CustomMetadataStep from "./CustomMetadataStep";
|
||||
|
||||
interface AdvancedOptionsStepProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
addCustomMetadata: (key?: string, value?: string) => void;
|
||||
removeCustomMetadata: (id: string) => void;
|
||||
updateCustomMetadata: (id: string, key: string, value: string) => void;
|
||||
}
|
||||
|
||||
const AdvancedOptionsStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
addCustomMetadata,
|
||||
removeCustomMetadata,
|
||||
updateCustomMetadata
|
||||
}: AdvancedOptionsStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Trapped Status */}
|
||||
<Select
|
||||
label={t('changeMetadata.trapped.label', 'Trapped Status')}
|
||||
value={parameters.trapped}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
onParameterChange('trapped', value as TrappedStatus);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || parameters.deleteAll}
|
||||
data={[
|
||||
{ value: TrappedStatus.UNKNOWN, label: t('changeMetadata.trapped.unknown', 'Unknown') },
|
||||
{ value: TrappedStatus.TRUE, label: t('changeMetadata.trapped.true', 'True') },
|
||||
{ value: TrappedStatus.FALSE, label: t('changeMetadata.trapped.false', 'False') }
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Custom Metadata */}
|
||||
<CustomMetadataStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
addCustomMetadata={addCustomMetadata}
|
||||
removeCustomMetadata={removeCustomMetadata}
|
||||
updateCustomMetadata={updateCustomMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedOptionsStep;
|
@ -0,0 +1,74 @@
|
||||
import { Stack, TextInput, Button, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
|
||||
interface CustomMetadataStepProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
addCustomMetadata: (key?: string, value?: string) => void;
|
||||
removeCustomMetadata: (id: string) => void;
|
||||
updateCustomMetadata: (id: string, key: string, value: string) => void;
|
||||
}
|
||||
|
||||
const CustomMetadataStep = ({
|
||||
parameters,
|
||||
disabled = false,
|
||||
addCustomMetadata,
|
||||
removeCustomMetadata,
|
||||
updateCustomMetadata
|
||||
}: CustomMetadataStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('changeMetadata.customFields.title', 'Custom Metadata')}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => addCustomMetadata()}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('changeMetadata.customFields.add', 'Add Field')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{parameters.customMetadata.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('changeMetadata.customFields.description', 'Add custom metadata fields to the document')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{parameters.customMetadata.map((entry) => (
|
||||
<Stack key={entry.id} gap="xs">
|
||||
<TextInput
|
||||
placeholder={t('changeMetadata.customFields.keyPlaceholder', 'Custom key')}
|
||||
value={entry.key}
|
||||
onChange={(e) => updateCustomMetadata(entry.id, e.target.value, entry.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={t('changeMetadata.customFields.valuePlaceholder', 'Custom value')}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateCustomMetadata(entry.id, entry.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => removeCustomMetadata(entry.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('changeMetadata.customFields.remove', 'Remove')}
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomMetadataStep;
|
@ -0,0 +1,28 @@
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
|
||||
interface DeleteAllStepProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DeleteAllStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: DeleteAllStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
label={t('changeMetadata.deleteAll.checkbox', 'Delete all metadata')}
|
||||
checked={parameters.deleteAll}
|
||||
onChange={(e) => onParameterChange('deleteAll', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAllStep;
|
@ -0,0 +1,42 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
|
||||
interface DocumentDatesStepProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DocumentDatesStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: DocumentDatesStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<DateTimePicker
|
||||
label={t('changeMetadata.creationDate.label', 'Creation Date')}
|
||||
placeholder={t('changeMetadata.creationDate.placeholder', 'Creation date')}
|
||||
value={parameters.creationDate}
|
||||
onChange={(date) => onParameterChange('creationDate', date ? new Date(date) : null)}
|
||||
disabled={disabled}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
label={t('changeMetadata.modificationDate.label', 'Modification Date')}
|
||||
placeholder={t('changeMetadata.modificationDate.placeholder', 'Modification date')}
|
||||
value={parameters.modificationDate}
|
||||
onChange={(date) => onParameterChange('modificationDate', date ? new Date(date) : null)}
|
||||
disabled={disabled}
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentDatesStep;
|
@ -0,0 +1,71 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
|
||||
interface StandardMetadataStepProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const StandardMetadataStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: StandardMetadataStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('changeMetadata.title.label', 'Title')}
|
||||
placeholder={t('changeMetadata.title.placeholder', 'Document title')}
|
||||
value={parameters.title}
|
||||
onChange={(e) => onParameterChange('title', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('changeMetadata.author.label', 'Author')}
|
||||
placeholder={t('changeMetadata.author.placeholder', 'Document author')}
|
||||
value={parameters.author}
|
||||
onChange={(e) => onParameterChange('author', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('changeMetadata.subject.label', 'Subject')}
|
||||
placeholder={t('changeMetadata.subject.placeholder', 'Document subject')}
|
||||
value={parameters.subject}
|
||||
onChange={(e) => onParameterChange('subject', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('changeMetadata.keywords.label', 'Keywords')}
|
||||
placeholder={t('changeMetadata.keywords.placeholder', 'Document keywords')}
|
||||
value={parameters.keywords}
|
||||
onChange={(e) => onParameterChange('keywords', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('changeMetadata.creator.label', 'Creator')}
|
||||
placeholder={t('changeMetadata.creator.placeholder', 'Document creator')}
|
||||
value={parameters.creator}
|
||||
onChange={(e) => onParameterChange('creator', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('changeMetadata.producer.label', 'Producer')}
|
||||
placeholder={t('changeMetadata.producer.placeholder', 'Document producer')}
|
||||
value={parameters.producer}
|
||||
onChange={(e) => onParameterChange('producer', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandardMetadataStep;
|
@ -0,0 +1,75 @@
|
||||
import { Stack, Text, Checkbox, Slider, NumberInput, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import NumberInputWithUnit from "../shared/NumberInputWithUnit";
|
||||
import { RemoveBlanksParameters } from "../../../hooks/tools/removeBlanks/useRemoveBlanksParameters";
|
||||
|
||||
interface RemoveBlanksSettingsProps {
|
||||
parameters: RemoveBlanksParameters;
|
||||
onParameterChange: <K extends keyof RemoveBlanksParameters>(key: K, value: RemoveBlanksParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RemoveBlanksSettings = ({ parameters, onParameterChange, disabled = false }: RemoveBlanksSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg" mt="md">
|
||||
<Stack gap="xs">
|
||||
<NumberInputWithUnit
|
||||
label={t('removeBlanks.threshold.label', 'Pixel Whiteness Threshold')}
|
||||
value={parameters.threshold}
|
||||
onChange={(v) => onParameterChange('threshold', typeof v === 'string' ? Number(v) : v)}
|
||||
unit=''
|
||||
min={0}
|
||||
max={255}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('removeBlanks.whitePercent.label', 'White Percent')}
|
||||
</Text>
|
||||
<Group align="center">
|
||||
<NumberInput
|
||||
value={parameters.whitePercent}
|
||||
onChange={(v) => onParameterChange('whitePercent', typeof v === 'number' ? v : 0.1)}
|
||||
min={0.1}
|
||||
max={100}
|
||||
step={0.1}
|
||||
size="sm"
|
||||
rightSection="%"
|
||||
style={{ width: '80px' }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.whitePercent}
|
||||
onChange={(value) => onParameterChange('whitePercent', value)}
|
||||
min={0.1}
|
||||
max={100}
|
||||
step={0.1}
|
||||
style={{ flex: 1 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
checked={parameters.includeBlankPages}
|
||||
onChange={(event) => onParameterChange('includeBlankPages', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<div>
|
||||
<Text size="sm">{t('removeBlanks.includeBlankPages.label', 'Include detected blank pages')}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoveBlanksSettings;
|
||||
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemovePagesParameters } from "../../../hooks/tools/removePages/useRemovePagesParameters";
|
||||
import { validatePageNumbers } from "../../../utils/pageSelection";
|
||||
|
||||
interface RemovePagesSettingsProps {
|
||||
parameters: RemovePagesParameters;
|
||||
onParameterChange: <K extends keyof RemovePagesParameters>(key: K, value: RemovePagesParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }: RemovePagesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePageNumbersChange = (value: string) => {
|
||||
// Allow user to type naturally - don't normalize input in real-time
|
||||
onParameterChange('pageNumbers', value);
|
||||
};
|
||||
|
||||
// Check if current input is valid
|
||||
const isValid = validatePageNumbers(parameters.pageNumbers);
|
||||
const hasValue = parameters.pageNumbers.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
|
||||
value={parameters.pageNumbers}
|
||||
onChange={(event) => handlePageNumbersChange(event.currentTarget.value)}
|
||||
placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')}
|
||||
disabled={disabled}
|
||||
required
|
||||
error={hasValue && !isValid ? t('removePages.pageNumbers.error', 'Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)') : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemovePagesSettings;
|
104
frontend/src/components/tools/rotate/RotateSettings.tsx
Normal file
104
frontend/src/components/tools/rotate/RotateSettings.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { Stack, Text, Box, ActionIcon, Group, Center } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import { RotateParametersHook } from "../../../hooks/tools/rotate/useRotateParameters";
|
||||
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
|
||||
import DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail";
|
||||
|
||||
interface RotateSettingsProps {
|
||||
parameters: RotateParametersHook;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFileStubs } = useSelectedFiles();
|
||||
|
||||
// Get the first selected file for preview
|
||||
const selectedStub = useMemo(() => {
|
||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
||||
}, [selectedFileStubs]);
|
||||
|
||||
// Get thumbnail for the selected file
|
||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnail(selectedStub?.thumbnailUrl || null);
|
||||
}, [selectedStub]);
|
||||
|
||||
// Calculate current angle display
|
||||
const currentAngle = parameters.parameters.angle;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Thumbnail Preview Section */}
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("rotate.preview.title", "Rotation Preview")}
|
||||
</Text>
|
||||
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
width: '280px',
|
||||
height: '280px',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: `rotate(${currentAngle}deg)`,
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DocumentThumbnail
|
||||
file={selectedStub}
|
||||
thumbnail={thumbnail}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Center>
|
||||
</Stack>
|
||||
|
||||
{/* Rotation Controls */}
|
||||
<Group justify="center" gap="lg">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
variant="outline"
|
||||
onClick={parameters.rotateAnticlockwise}
|
||||
disabled={disabled}
|
||||
aria-label={t("rotate.rotateLeft", "Rotate Anticlockwise")}
|
||||
title={t("rotate.rotateLeft", "Rotate Anticlockwise")}
|
||||
>
|
||||
<RotateLeftIcon style={{ fontSize: '1.5rem' }} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
variant="outline"
|
||||
onClick={parameters.rotateClockwise}
|
||||
disabled={disabled}
|
||||
aria-label={t("rotate.rotateRight", "Rotate Clockwise")}
|
||||
title={t("rotate.rotateRight", "Rotate Clockwise")}
|
||||
>
|
||||
<RotateRightIcon style={{ fontSize: '1.5rem' }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RotateSettings;
|
@ -48,6 +48,7 @@ const renderTooltipTitle = (
|
||||
tips={tooltip.tips}
|
||||
header={tooltip.header}
|
||||
sidebarTooltip={true}
|
||||
pinOnClick={true}
|
||||
>
|
||||
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Text fw={400} size="sm">
|
||||
|
108
frontend/src/components/tooltips/useChangeMetadataTips.ts
Normal file
108
frontend/src/components/tooltips/useChangeMetadataTips.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useDeleteAllTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("changeMetadata.tooltip.deleteAll.title", "Remove Existing Metadata")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
description: t("changeMetadata.tooltip.deleteAll.text", "Complete metadata deletion to ensure privacy."),
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useStandardMetadataTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("changeMetadata.tooltip.standardFields.title", "Standard Fields")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
description: t("changeMetadata.tooltip.standardFields.text", "Common PDF metadata fields that describe the document."),
|
||||
bullets: [
|
||||
t("changeMetadata.tooltip.standardFields.bullet1", "Title: Document name or heading"),
|
||||
t("changeMetadata.tooltip.standardFields.bullet2", "Author: Person who created the document"),
|
||||
t("changeMetadata.tooltip.standardFields.bullet3", "Subject: Brief description of content"),
|
||||
t("changeMetadata.tooltip.standardFields.bullet4", "Keywords: Search terms for the document"),
|
||||
t("changeMetadata.tooltip.standardFields.bullet5", "Creator/Producer: Software used to create the PDF")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useDocumentDatesTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("changeMetadata.tooltip.dates.title", "Date Fields")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
description: t("changeMetadata.tooltip.dates.text", "When the document was created and modified."),
|
||||
bullets: [
|
||||
t("changeMetadata.tooltip.dates.bullet1", "Creation Date: When original document was made"),
|
||||
t("changeMetadata.tooltip.dates.bullet2", "Modification Date: When last changed"),
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useCustomMetadataTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("changeMetadata.tooltip.customFields.title", "Custom Metadata")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
description: t("changeMetadata.tooltip.customFields.text", "Add your own custom key-value metadata pairs."),
|
||||
bullets: [
|
||||
t("changeMetadata.tooltip.customFields.bullet1", "Add any custom fields relevant to your document"),
|
||||
t("changeMetadata.tooltip.customFields.bullet2", "Examples: Department, Project, Version, Status"),
|
||||
t("changeMetadata.tooltip.customFields.bullet3", "Both key and value are required for each entry")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useAdvancedOptionsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("changeMetadata.tooltip.advanced.title", "Advanced Options")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("changeMetadata.tooltip.advanced.trapped.title", "Trapped Status"),
|
||||
description: t("changeMetadata.tooltip.advanced.trapped.description", "Indicates if document is prepared for high-quality printing."),
|
||||
bullets: [
|
||||
t("changeMetadata.tooltip.advanced.trapped.bullet1", "True: Document has been trapped for printing"),
|
||||
t("changeMetadata.tooltip.advanced.trapped.bullet2", "False: Document has not been trapped"),
|
||||
t("changeMetadata.tooltip.advanced.trapped.bullet3", "Unknown: Trapped status is not specified")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("changeMetadata.tooltip.customFields.title", "Custom Metadata"),
|
||||
description: t("changeMetadata.tooltip.customFields.text", "Add your own custom key-value metadata pairs."),
|
||||
bullets: [
|
||||
t("changeMetadata.tooltip.customFields.bullet1", "Add any custom fields relevant to your document"),
|
||||
t("changeMetadata.tooltip.customFields.bullet2", "Examples: Department, Project, Version, Status"),
|
||||
t("changeMetadata.tooltip.customFields.bullet3", "Both key and value are required for each entry")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
44
frontend/src/components/tooltips/usePageSelectionTips.ts
Normal file
44
frontend/src/components/tooltips/usePageSelectionTips.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const usePageSelectionTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('bulkSelection.header.title', 'Page Selection Guide'),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('bulkSelection.syntax.title', 'Syntax Basics'),
|
||||
description: t('bulkSelection.syntax.text', 'Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.'),
|
||||
bullets: [
|
||||
t('bulkSelection.syntax.bullets.numbers', 'Numbers/ranges: 5, 10-20'),
|
||||
t('bulkSelection.syntax.bullets.keywords', 'Keywords: odd, even'),
|
||||
t('bulkSelection.syntax.bullets.progressions', 'Progressions: 3n, 4n+1'),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('bulkSelection.operators.title', 'Operators'),
|
||||
description: t('bulkSelection.operators.text', 'AND has higher precedence than comma. NOT applies within the document range.'),
|
||||
bullets: [
|
||||
t('bulkSelection.operators.and', 'AND: & or "and" — require both conditions (e.g., 1-50 & even)'),
|
||||
t('bulkSelection.operators.comma', 'Comma: , or | — combine selections (e.g., 1-10, 20)'),
|
||||
t('bulkSelection.operators.not', 'NOT: ! or "not" — exclude pages (e.g., 3n & not 30)'),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('bulkSelection.examples.title', 'Examples'),
|
||||
bullets: [
|
||||
`${t('bulkSelection.examples.first50', 'First 50')}: 1-50`,
|
||||
`${t('bulkSelection.examples.last50', 'Last 50')}: 451-500`,
|
||||
`${t('bulkSelection.examples.every3rd', 'Every 3rd')}: 3n`,
|
||||
`${t('bulkSelection.examples.oddWithinExcluding', 'Odd within 1-20 excluding 5-7')}: 1-20 & odd & !5-7`,
|
||||
`${t('bulkSelection.examples.combineSets', 'Combine sets')}: 1-50, 451-500`,
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
|
41
frontend/src/components/tooltips/useRemoveBlanksTips.ts
Normal file
41
frontend/src/components/tooltips/useRemoveBlanksTips.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useRemoveBlanksTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("removeBlanks.tooltip.header.title", "Remove Blank Pages Settings"),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("removeBlanks.tooltip.threshold.title", "Pixel Whiteness Threshold"),
|
||||
description: t("removeBlanks.tooltip.threshold.text", "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page."),
|
||||
bullets: [
|
||||
t("removeBlanks.tooltip.threshold.bullet1", "0 = Pure black (most restrictive)"),
|
||||
t("removeBlanks.tooltip.threshold.bullet2", "128 = Medium gray"),
|
||||
t("removeBlanks.tooltip.threshold.bullet3", "255 = Pure white (least restrictive)")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("removeBlanks.tooltip.whitePercent.title", "White Percentage Threshold"),
|
||||
description: t("removeBlanks.tooltip.whitePercent.text", "Sets the minimum percentage of white pixels required for a page to be considered blank and removed."),
|
||||
bullets: [
|
||||
t("removeBlanks.tooltip.whitePercent.bullet1", "Lower values (e.g., 80%) = More pages removed"),
|
||||
t("removeBlanks.tooltip.whitePercent.bullet2", "Higher values (e.g., 95%) = Only very blank pages removed"),
|
||||
t("removeBlanks.tooltip.whitePercent.bullet3", "Use higher values for documents with light backgrounds")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("removeBlanks.tooltip.includeBlankPages.title", "Include Detected Blank Pages"),
|
||||
description: t("removeBlanks.tooltip.includeBlankPages.text", "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document."),
|
||||
bullets: [
|
||||
t("removeBlanks.tooltip.includeBlankPages.bullet1", "Useful for reviewing what was removed"),
|
||||
t("removeBlanks.tooltip.includeBlankPages.bullet2", "Helps verify the detection accuracy"),
|
||||
t("removeBlanks.tooltip.includeBlankPages.bullet3", "Can be disabled to reduce output file size")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
34
frontend/src/components/tooltips/useRemovePagesTips.ts
Normal file
34
frontend/src/components/tooltips/useRemovePagesTips.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useRemovePagesTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("removePages.tooltip.header.title", "Remove Pages Settings"),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("removePages.tooltip.pageNumbers.title", "Page Selection"),
|
||||
description: t("removePages.tooltip.pageNumbers.text", "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions."),
|
||||
bullets: [
|
||||
t("removePages.tooltip.pageNumbers.bullet1", "Individual pages: 1,3,5 (removes pages 1, 3, and 5)"),
|
||||
t("removePages.tooltip.pageNumbers.bullet2", "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)"),
|
||||
t("removePages.tooltip.pageNumbers.bullet3", "Mathematical: 2n+1 (removes odd pages)"),
|
||||
t("removePages.tooltip.pageNumbers.bullet4", "Open ranges: 5- (removes from page 5 to end)")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("removePages.tooltip.examples.title", "Common Examples"),
|
||||
description: t("removePages.tooltip.examples.text", "Here are some common page selection patterns:"),
|
||||
bullets: [
|
||||
t("removePages.tooltip.examples.bullet1", "Remove first page: 1"),
|
||||
t("removePages.tooltip.examples.bullet2", "Remove last 3 pages: -3"),
|
||||
t("removePages.tooltip.examples.bullet3", "Remove every other page: 2n"),
|
||||
t("removePages.tooltip.examples.bullet4", "Remove specific scattered pages: 1,5,10,15")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
21
frontend/src/components/tooltips/useRotateTips.ts
Normal file
21
frontend/src/components/tooltips/useRotateTips.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useRotateTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("rotate.tooltip.header.title", "Rotate Settings Overview"),
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
description: t("rotate.tooltip.description.text", "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation."),
|
||||
},
|
||||
{
|
||||
title: t("rotate.tooltip.controls.title", "Controls"),
|
||||
description: t("rotate.tooltip.controls.text", "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees."),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
@ -123,12 +123,12 @@ export function useStirlingFileStub(fileId: FileId): { file?: File; record?: Sti
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getFiles(),
|
||||
records: selectors.getStirlingFileStubs(),
|
||||
fileStubs: selectors.getStirlingFileStubs(),
|
||||
fileIds: state.files.ids
|
||||
}), [state.files.ids, selectors]);
|
||||
}
|
||||
@ -136,12 +136,12 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
|
||||
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
selectedFiles: selectors.getSelectedFiles(),
|
||||
selectedRecords: selectors.getSelectedStirlingFileStubs(),
|
||||
selectedFileStubs: selectors.getSelectedStirlingFileStubs(),
|
||||
selectedFileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
|
@ -8,6 +8,8 @@ import ConvertPanel from "../tools/Convert";
|
||||
import Sanitize from "../tools/Sanitize";
|
||||
import AddPassword from "../tools/AddPassword";
|
||||
import ChangePermissions from "../tools/ChangePermissions";
|
||||
import RemoveBlanks from "../tools/RemoveBlanks";
|
||||
import RemovePages from "../tools/RemovePages";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
@ -19,6 +21,8 @@ import SingleLargePage from "../tools/SingleLargePage";
|
||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||
import Flatten from "../tools/Flatten";
|
||||
import Rotate from "../tools/Rotate";
|
||||
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@ -37,6 +41,8 @@ import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
|
||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
||||
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
@ -50,12 +56,14 @@ import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@ -297,10 +305,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
"change-metadata": {
|
||||
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.changeMetadata.title", "Change Metadata"),
|
||||
component: null,
|
||||
component: ChangeMetadata,
|
||||
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
maxFiles: -1,
|
||||
endpoints: ["update-metadata"],
|
||||
operationConfig: changeMetadataOperationConfig,
|
||||
settingsComponent: ChangeMetadataSingleStep,
|
||||
},
|
||||
// Page Formatting
|
||||
|
||||
@ -315,10 +327,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
rotate: {
|
||||
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.rotate.title", "Rotate"),
|
||||
component: null,
|
||||
component: Rotate,
|
||||
description: t("home.rotate.desc", "Easily rotate your PDFs."),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["rotate-pdf"],
|
||||
operationConfig: rotateOperationConfig,
|
||||
settingsComponent: RotateSettings,
|
||||
},
|
||||
split: {
|
||||
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@ -419,18 +435,22 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
removePages: {
|
||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.removePages.title", "Remove Pages"),
|
||||
component: null,
|
||||
component: RemovePages,
|
||||
description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-pages"],
|
||||
},
|
||||
"remove-blank-pages": {
|
||||
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.removeBlanks.title", "Remove Blank Pages"),
|
||||
component: null,
|
||||
component: RemoveBlanks,
|
||||
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-blanks"],
|
||||
},
|
||||
"remove-annotations": {
|
||||
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
||||
|
@ -0,0 +1,144 @@
|
||||
import { buildChangeMetadataFormData } from './useChangeMetadataOperation';
|
||||
import { ChangeMetadataParameters } from './useChangeMetadataParameters';
|
||||
import { TrappedStatus } from '../../../types/metadata';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
describe('buildChangeMetadataFormData', () => {
|
||||
const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' });
|
||||
|
||||
const defaultParams: ChangeMetadataParameters = {
|
||||
title: '',
|
||||
author: '',
|
||||
subject: '',
|
||||
keywords: '',
|
||||
creator: '',
|
||||
producer: '',
|
||||
creationDate: null,
|
||||
modificationDate: null,
|
||||
trapped: TrappedStatus.UNKNOWN,
|
||||
customMetadata: [],
|
||||
deleteAll: false,
|
||||
};
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'should build FormData with basic parameters',
|
||||
params: {
|
||||
...defaultParams,
|
||||
title: 'Test Document',
|
||||
author: 'John Doe',
|
||||
deleteAll: true,
|
||||
},
|
||||
expectedFormData: {
|
||||
fileInput: mockFile,
|
||||
title: 'Test Document',
|
||||
author: 'John Doe',
|
||||
deleteAll: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'should handle empty string values',
|
||||
params: defaultParams,
|
||||
expectedFormData: {
|
||||
title: '',
|
||||
author: '',
|
||||
subject: '',
|
||||
keywords: '',
|
||||
creator: '',
|
||||
producer: '',
|
||||
creationDate: '',
|
||||
modificationDate: '',
|
||||
trapped: TrappedStatus.UNKNOWN,
|
||||
deleteAll: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'should include all standard metadata fields',
|
||||
params: {
|
||||
...defaultParams,
|
||||
title: 'Test Title',
|
||||
author: 'Test Author',
|
||||
subject: 'Test Subject',
|
||||
keywords: 'test, keywords',
|
||||
creator: 'Test Creator',
|
||||
producer: 'Test Producer',
|
||||
creationDate: new Date('2025/01/17 14:30:00'),
|
||||
modificationDate: new Date('2025/01/17 15:30:00'),
|
||||
trapped: TrappedStatus.TRUE,
|
||||
},
|
||||
expectedFormData: {
|
||||
title: 'Test Title',
|
||||
author: 'Test Author',
|
||||
subject: 'Test Subject',
|
||||
keywords: 'test, keywords',
|
||||
creator: 'Test Creator',
|
||||
producer: 'Test Producer',
|
||||
creationDate: '2025/01/17 14:30:00',
|
||||
modificationDate: '2025/01/17 15:30:00',
|
||||
trapped: TrappedStatus.TRUE,
|
||||
},
|
||||
},
|
||||
])('$name', ({ params, expectedFormData }) => {
|
||||
const formData = buildChangeMetadataFormData(params, mockFile);
|
||||
|
||||
Object.entries(expectedFormData).forEach(([key, value]) => {
|
||||
expect(formData.get(key)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle custom metadata with proper indexing', () => {
|
||||
const params = {
|
||||
...defaultParams,
|
||||
customMetadata: [
|
||||
{ key: 'Department', value: 'Engineering', id: 'custom1' },
|
||||
{ key: 'Project', value: 'Test Project', id: 'custom2' },
|
||||
{ key: 'Status', value: 'Draft', id: 'custom3' },
|
||||
],
|
||||
};
|
||||
|
||||
const formData = buildChangeMetadataFormData(params, mockFile);
|
||||
|
||||
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
|
||||
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
|
||||
expect(formData.get('allRequestParams[customKey2]')).toBe('Project');
|
||||
expect(formData.get('allRequestParams[customValue2]')).toBe('Test Project');
|
||||
expect(formData.get('allRequestParams[customKey3]')).toBe('Status');
|
||||
expect(formData.get('allRequestParams[customValue3]')).toBe('Draft');
|
||||
});
|
||||
|
||||
test('should skip custom metadata with empty keys or values', () => {
|
||||
const params = {
|
||||
...defaultParams,
|
||||
customMetadata: [
|
||||
{ key: 'Department', value: 'Engineering', id: 'custom1' },
|
||||
{ key: '', value: 'No Key', id: 'custom2' },
|
||||
{ key: 'No Value', value: '', id: 'custom3' },
|
||||
{ key: ' ', value: 'Whitespace Key', id: 'custom4' },
|
||||
{ key: 'Valid', value: 'Valid Value', id: 'custom5' },
|
||||
],
|
||||
};
|
||||
|
||||
const formData = buildChangeMetadataFormData(params, mockFile);
|
||||
|
||||
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
|
||||
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
|
||||
expect(formData.get('allRequestParams[customKey2]')).toBe('Valid');
|
||||
expect(formData.get('allRequestParams[customValue2]')).toBe('Valid Value');
|
||||
expect(formData.get('allRequestParams[customKey3]')).toBeNull();
|
||||
expect(formData.get('allRequestParams[customKey4]')).toBeNull();
|
||||
});
|
||||
|
||||
test('should trim whitespace from custom metadata', () => {
|
||||
const params = {
|
||||
...defaultParams,
|
||||
customMetadata: [
|
||||
{ key: ' Department ', value: ' Engineering ', id: 'custom1' },
|
||||
],
|
||||
};
|
||||
|
||||
const formData = buildChangeMetadataFormData(params, mockFile);
|
||||
|
||||
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
|
||||
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
|
||||
});
|
||||
});
|
@ -0,0 +1,71 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ChangeMetadataParameters, defaultParameters } from './useChangeMetadataParameters';
|
||||
|
||||
// Helper function to format Date object to string
|
||||
const formatDateForBackend = (date: Date | null): string => {
|
||||
if (!date) return '';
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// Standard metadata fields
|
||||
formData.append("title", parameters.title || "");
|
||||
formData.append("author", parameters.author || "");
|
||||
formData.append("subject", parameters.subject || "");
|
||||
formData.append("keywords", parameters.keywords || "");
|
||||
formData.append("creator", parameters.creator || "");
|
||||
formData.append("producer", parameters.producer || "");
|
||||
|
||||
// Date fields - convert Date objects to strings
|
||||
formData.append("creationDate", formatDateForBackend(parameters.creationDate));
|
||||
formData.append("modificationDate", formatDateForBackend(parameters.modificationDate));
|
||||
|
||||
// Trapped status
|
||||
formData.append("trapped", parameters.trapped || "");
|
||||
|
||||
// Delete all metadata flag
|
||||
formData.append("deleteAll", parameters.deleteAll.toString());
|
||||
|
||||
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
|
||||
let keyNumber = 0;
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
|
||||
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const changeMetadataOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildChangeMetadataFormData,
|
||||
operationType: 'changeMetadata',
|
||||
endpoint: '/api/v1/misc/update-metadata',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useChangeMetadataOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ChangeMetadataParameters>({
|
||||
...changeMetadataOperationConfig,
|
||||
filePrefix: t('changeMetadata.filenamePrefix', 'metadata') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('changeMetadata.error.failed', 'An error occurred while changing the PDF metadata.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,168 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { defaultParameters, useChangeMetadataParameters } from './useChangeMetadataParameters';
|
||||
import { TrappedStatus } from '../../../types/metadata';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
describe('useChangeMetadataParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
describe('parameter updates', () => {
|
||||
test.each([
|
||||
{ paramName: 'title', value: 'Test Document' },
|
||||
{ paramName: 'author', value: 'John Doe' },
|
||||
{ paramName: 'subject', value: 'Test Subject' },
|
||||
{ paramName: 'keywords', value: 'test, metadata' },
|
||||
{ paramName: 'creator', value: 'Test Creator' },
|
||||
{ paramName: 'producer', value: 'Test Producer' },
|
||||
{ paramName: 'creationDate', value: new Date('2025/01/17 14:30:00') },
|
||||
{ paramName: 'modificationDate', value: new Date('2025/01/17 15:30:00') },
|
||||
{ paramName: 'trapped', value: TrappedStatus.TRUE },
|
||||
{ paramName: 'deleteAll', value: true },
|
||||
] as const)('should update $paramName parameter', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
});
|
||||
|
||||
expect(result.current.parameters[paramName]).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test.each([
|
||||
{ description: 'deleteAll is true', updates: { deleteAll: true }, expected: true },
|
||||
{ description: 'has title', updates: { title: 'Test Document' }, expected: true },
|
||||
{ description: 'has author', updates: { author: 'John Doe' }, expected: true },
|
||||
{ description: 'has subject', updates: { subject: 'Test Subject' }, expected: true },
|
||||
{ description: 'has keywords', updates: { keywords: 'test' }, expected: true },
|
||||
{ description: 'has creator', updates: { creator: 'Test Creator' }, expected: true },
|
||||
{ description: 'has producer', updates: { producer: 'Test Producer' }, expected: true },
|
||||
{ description: 'has creation date', updates: { creationDate: new Date('2025/01/17 14:30:00') }, expected: true },
|
||||
{ description: 'has modification date', updates: { modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
|
||||
{ description: 'has trapped status', updates: { trapped: TrappedStatus.TRUE }, expected: true },
|
||||
{ description: 'no meaningful content', updates: {}, expected: false },
|
||||
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
|
||||
])('should validate correctly when $description', ({ updates, expected }) => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
result.current.updateParameter(key as keyof typeof updates, value);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: 'valid creation date', updates: { title: 'Test', creationDate: new Date('2025/01/17 14:30:00') }, expected: true },
|
||||
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
|
||||
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
|
||||
])('should validate dates correctly with $description', ({ updates, expected }) => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
result.current.updateParameter(key as keyof typeof updates, value);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom metadata', () => {
|
||||
test('should add custom metadata with sequential IDs', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
});
|
||||
|
||||
expect(result.current.parameters.customMetadata).toHaveLength(1);
|
||||
expect(result.current.parameters.customMetadata[0]).toEqual({
|
||||
key: '',
|
||||
value: '',
|
||||
id: expect.stringMatching(/^custom\d+$/)
|
||||
});
|
||||
});
|
||||
|
||||
test('should remove custom metadata by ID', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
});
|
||||
|
||||
const customId = result.current.parameters.customMetadata[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.removeCustomMetadata(customId);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.customMetadata).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should update custom metadata by ID', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
});
|
||||
|
||||
const customId = result.current.parameters.customMetadata[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.updateCustomMetadata(customId, 'Department', 'Engineering');
|
||||
});
|
||||
|
||||
expect(result.current.parameters.customMetadata[0]).toEqual({
|
||||
key: 'Department',
|
||||
value: 'Engineering',
|
||||
id: customId
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate with custom metadata', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
});
|
||||
|
||||
const customId = result.current.parameters.customMetadata[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.updateCustomMetadata(customId, 'Department', 'Engineering');
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
});
|
||||
|
||||
test('should generate unique IDs for multiple custom entries', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
});
|
||||
}
|
||||
|
||||
const ids = result.current.parameters.customMetadata.map(entry => entry.id);
|
||||
expect(ids).toHaveLength(3);
|
||||
expect(new Set(ids).size).toBe(3); // All unique
|
||||
expect(ids.every(id => id.startsWith('custom'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('update-metadata');
|
||||
});
|
||||
});
|
@ -0,0 +1,136 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { TrappedStatus, CustomMetadataEntry } from '../../../types/metadata';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ChangeMetadataParameters extends BaseParameters {
|
||||
// Standard PDF metadata fields
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string;
|
||||
creator: string;
|
||||
producer: string;
|
||||
|
||||
// Date fields
|
||||
creationDate: Date | null;
|
||||
modificationDate: Date | null;
|
||||
|
||||
// Trapped status
|
||||
trapped: TrappedStatus;
|
||||
|
||||
// Custom metadata entries
|
||||
customMetadata: CustomMetadataEntry[];
|
||||
|
||||
// Delete all metadata option
|
||||
deleteAll: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: ChangeMetadataParameters = {
|
||||
title: '',
|
||||
author: '',
|
||||
subject: '',
|
||||
keywords: '',
|
||||
creator: '',
|
||||
producer: '',
|
||||
creationDate: null,
|
||||
modificationDate: null,
|
||||
trapped: TrappedStatus.UNKNOWN,
|
||||
customMetadata: [],
|
||||
deleteAll: false,
|
||||
};
|
||||
|
||||
// Global counter for custom metadata IDs
|
||||
let customMetadataIdCounter = 1;
|
||||
|
||||
// Utility functions that can work with external parameters
|
||||
export const createCustomMetadataFunctions = (
|
||||
parameters: ChangeMetadataParameters,
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void
|
||||
) => {
|
||||
const addCustomMetadata = (key: string = '', value: string = '') => {
|
||||
const newEntry: CustomMetadataEntry = {
|
||||
key,
|
||||
value,
|
||||
id: `custom${customMetadataIdCounter++}`,
|
||||
};
|
||||
|
||||
onParameterChange('customMetadata', [
|
||||
...parameters.customMetadata,
|
||||
newEntry,
|
||||
]);
|
||||
};
|
||||
|
||||
const removeCustomMetadata = (id: string) => {
|
||||
onParameterChange('customMetadata',
|
||||
parameters.customMetadata.filter(entry => entry.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const updateCustomMetadata = (id: string, key: string, value: string) => {
|
||||
onParameterChange('customMetadata',
|
||||
parameters.customMetadata.map(entry =>
|
||||
entry.id === id ? { ...entry, key, value } : entry
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
addCustomMetadata,
|
||||
removeCustomMetadata,
|
||||
updateCustomMetadata
|
||||
};
|
||||
};
|
||||
|
||||
// Validation function
|
||||
const validateParameters = (params: ChangeMetadataParameters): boolean => {
|
||||
// If deleteAll is true, no other validation needed
|
||||
if (params.deleteAll) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// At least one field should have content for the operation to be meaningful
|
||||
const hasStandardMetadata = !!(
|
||||
params.title.trim()
|
||||
|| params.author.trim()
|
||||
|| params.subject.trim()
|
||||
|| params.keywords.trim()
|
||||
|| params.creator.trim()
|
||||
|| params.producer.trim()
|
||||
|| params.creationDate
|
||||
|| params.modificationDate
|
||||
|| params.trapped !== TrappedStatus.UNKNOWN
|
||||
);
|
||||
|
||||
const hasCustomMetadata = params.customMetadata.some(
|
||||
entry => entry.key.trim() && entry.value.trim()
|
||||
);
|
||||
|
||||
return hasStandardMetadata || hasCustomMetadata;
|
||||
};
|
||||
|
||||
export type ChangeMetadataParametersHook = BaseParametersHook<ChangeMetadataParameters> & {
|
||||
addCustomMetadata: (key?: string, value?: string) => void;
|
||||
removeCustomMetadata: (id: string) => void;
|
||||
updateCustomMetadata: (id: string, key: string, value: string) => void;
|
||||
};
|
||||
|
||||
export const useChangeMetadataParameters = (): ChangeMetadataParametersHook => {
|
||||
const base = useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'update-metadata',
|
||||
validateFn: validateParameters,
|
||||
});
|
||||
|
||||
// Use the utility functions with the hook's parameters and updateParameter
|
||||
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(
|
||||
base.parameters,
|
||||
base.updateParameter,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
addCustomMetadata,
|
||||
removeCustomMetadata,
|
||||
updateCustomMetadata
|
||||
};
|
||||
};
|
@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { extractPDFMetadata } from "../../../services/pdfMetadataService";
|
||||
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
|
||||
import { ChangeMetadataParameters } from "./useChangeMetadataParameters";
|
||||
|
||||
interface MetadataExtractionParams {
|
||||
updateParameter: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
}
|
||||
|
||||
export const useMetadataExtraction = (params: MetadataExtractionParams) => {
|
||||
const { selectedFiles } = useSelectedFiles();
|
||||
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
|
||||
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
|
||||
const previousFileCountRef = useRef(0);
|
||||
|
||||
// Reset extraction state only when files are cleared (length goes to 0)
|
||||
useEffect(() => {
|
||||
if (previousFileCountRef.current > 0 && selectedFiles.length === 0) {
|
||||
setHasExtractedMetadata(false);
|
||||
}
|
||||
previousFileCountRef.current = selectedFiles.length;
|
||||
}, [selectedFiles]);
|
||||
|
||||
// Extract metadata from first file when files change
|
||||
useEffect(() => {
|
||||
const extractMetadata = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const firstFile = selectedFiles[0];
|
||||
|
||||
if (hasExtractedMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExtractingMetadata(true);
|
||||
|
||||
const result = await extractPDFMetadata(firstFile);
|
||||
|
||||
if (result.success) {
|
||||
const metadata = result.metadata;
|
||||
|
||||
// Pre-populate all fields with extracted metadata
|
||||
params.updateParameter('title', metadata.title);
|
||||
params.updateParameter('author', metadata.author);
|
||||
params.updateParameter('subject', metadata.subject);
|
||||
params.updateParameter('keywords', metadata.keywords);
|
||||
params.updateParameter('creator', metadata.creator);
|
||||
params.updateParameter('producer', metadata.producer);
|
||||
params.updateParameter('creationDate', metadata.creationDate ? new Date(metadata.creationDate) : null);
|
||||
params.updateParameter('modificationDate', metadata.modificationDate ? new Date(metadata.modificationDate) : null);
|
||||
params.updateParameter('trapped', metadata.trapped);
|
||||
params.updateParameter('customMetadata', metadata.customMetadata);
|
||||
|
||||
setHasExtractedMetadata(true);
|
||||
} else {
|
||||
console.warn('Failed to extract metadata:', result.error);
|
||||
}
|
||||
|
||||
setIsExtractingMetadata(false);
|
||||
};
|
||||
|
||||
extractMetadata();
|
||||
}, [selectedFiles, hasExtractedMetadata, params]);
|
||||
|
||||
return {
|
||||
isExtractingMetadata,
|
||||
hasExtractedMetadata,
|
||||
};
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveBlanksParameters, defaultParameters } from './useRemoveBlanksParameters';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
|
||||
export const buildRemoveBlanksFormData = (parameters: RemoveBlanksParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('threshold', String(parameters.threshold));
|
||||
formData.append('whitePercent', String(parameters.whitePercent));
|
||||
// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const removeBlanksOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRemoveBlanksFormData,
|
||||
operationType: 'remove-blanks',
|
||||
endpoint: '/api/v1/misc/remove-blanks',
|
||||
defaultParameters,
|
||||
} as const satisfies ToolOperationConfig<RemoveBlanksParameters>;
|
||||
|
||||
export const useRemoveBlanksOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { extractZipFiles } = useToolResources();
|
||||
|
||||
const responseHandler = useCallback(async (blob: Blob): Promise<File[]> => {
|
||||
// Backend always returns a ZIP file containing the processed PDFs
|
||||
return await extractZipFiles(blob);
|
||||
}, [extractZipFiles]);
|
||||
|
||||
return useToolOperation<RemoveBlanksParameters>({
|
||||
...removeBlanksOperationConfig,
|
||||
responseHandler,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('removeBlanks.error.failed', 'Failed to remove blank pages')
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,26 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface RemoveBlanksParameters extends BaseParameters {
|
||||
threshold: number; // 0-255
|
||||
whitePercent: number; // 0.1-100
|
||||
includeBlankPages: boolean; // whether to include detected blank pages in output
|
||||
}
|
||||
|
||||
export const defaultParameters: RemoveBlanksParameters = {
|
||||
threshold: 10,
|
||||
whitePercent: 99.9,
|
||||
includeBlankPages: false,
|
||||
};
|
||||
|
||||
export type RemoveBlanksParametersHook = BaseParametersHook<RemoveBlanksParameters>;
|
||||
|
||||
export const useRemoveBlanksParameters = (): RemoveBlanksParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-blanks',
|
||||
validateFn: (p) => p.threshold >= 0 && p.threshold <= 255 && p.whitePercent > 0 && p.whitePercent <= 100,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemovePagesParameters, defaultParameters } from './useRemovePagesParameters';
|
||||
// import { useToolResources } from '../shared/useToolResources';
|
||||
|
||||
export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
|
||||
formData.append('pageNumbers', cleaned);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const removePagesOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRemovePagesFormData,
|
||||
operationType: 'remove-pages',
|
||||
endpoint: '/api/v1/general/remove-pages',
|
||||
defaultParameters,
|
||||
} as const satisfies ToolOperationConfig<RemovePagesParameters>;
|
||||
|
||||
export const useRemovePagesOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<RemovePagesParameters>({
|
||||
...removePagesOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('removePages.error.failed', 'Failed to remove pages')
|
||||
)
|
||||
});
|
||||
};
|
@ -0,0 +1,21 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
import { validatePageNumbers } from '../../../utils/pageSelection';
|
||||
|
||||
export interface RemovePagesParameters extends BaseParameters {
|
||||
pageNumbers: string; // comma-separated page numbers or ranges (e.g., "1,3,5-8")
|
||||
}
|
||||
|
||||
export const defaultParameters: RemovePagesParameters = {
|
||||
pageNumbers: '',
|
||||
};
|
||||
|
||||
export type RemovePagesParametersHook = BaseParametersHook<RemovePagesParameters>;
|
||||
|
||||
export const useRemovePagesParameters = (): RemovePagesParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-pages',
|
||||
validateFn: (p) => validatePageNumbers(p.pageNumbers),
|
||||
});
|
||||
};
|
101
frontend/src/hooks/tools/rotate/useRotateOperation.test.ts
Normal file
101
frontend/src/hooks/tools/rotate/useRotateOperation.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useRotateOperation } from './useRotateOperation';
|
||||
import type { RotateParameters } from './useRotateParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation');
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the translation hook
|
||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Mock the error handler
|
||||
vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
|
||||
}));
|
||||
|
||||
// Import the mocked function
|
||||
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
describe('useRotateOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<RotateParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
downloadUrl: null,
|
||||
downloadFilename: '',
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
status: '',
|
||||
isGeneratingThumbnails: false,
|
||||
progress: null,
|
||||
executeOperation: vi.fn(),
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation: vi.fn(),
|
||||
undoOperation: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ angle: 0, expectedNormalized: 0 },
|
||||
{ angle: 90, expectedNormalized: 90 },
|
||||
{ angle: 180, expectedNormalized: 180 },
|
||||
{ angle: 270, expectedNormalized: 270 },
|
||||
{ angle: 360, expectedNormalized: 0 },
|
||||
{ angle: -90, expectedNormalized: 270 },
|
||||
{ angle: -180, expectedNormalized: 180 },
|
||||
{ angle: -270, expectedNormalized: 90 },
|
||||
{ angle: 450, expectedNormalized: 90 },
|
||||
])('should create form data correctly with angle $angle (normalized to $expectedNormalized)', ({ angle, expectedNormalized }) => {
|
||||
renderHook(() => useRotateOperation());
|
||||
|
||||
const callArgs = getToolConfig();
|
||||
|
||||
const testParameters: RotateParameters = { angle };
|
||||
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const formData = callArgs.buildFormData(testParameters, testFile);
|
||||
|
||||
// Verify the form data contains the file
|
||||
expect(formData.get('fileInput')).toBe(testFile);
|
||||
|
||||
// Verify angle parameter is normalized for backend
|
||||
expect(formData.get('angle')).toBe(expectedNormalized.toString());
|
||||
});
|
||||
|
||||
test('should use correct translation for error messages', () => {
|
||||
renderHook(() => useRotateOperation());
|
||||
|
||||
expect(mockT).toHaveBeenCalledWith(
|
||||
'rotate.error.failed',
|
||||
'An error occurred while rotating the PDF.'
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/general/rotate-pdf' },
|
||||
{ property: 'operationType' as const, expectedValue: 'rotate' }
|
||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||
renderHook(() => useRotateOperation());
|
||||
|
||||
const callArgs = getToolConfig();
|
||||
expect(callArgs[property]).toBe(expectedValue);
|
||||
});
|
||||
});
|
31
frontend/src/hooks/tools/rotate/useRotateOperation.ts
Normal file
31
frontend/src/hooks/tools/rotate/useRotateOperation.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RotateParameters, defaultParameters, normalizeAngle } from './useRotateParameters';
|
||||
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildRotateFormData = (parameters: RotateParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
// Normalize angle for backend (0, 90, 180, 270)
|
||||
formData.append("angle", normalizeAngle(parameters.angle).toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const rotateOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRotateFormData,
|
||||
operationType: 'rotate',
|
||||
endpoint: '/api/v1/general/rotate-pdf',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useRotateOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<RotateParameters>({
|
||||
...rotateOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('rotate.error.failed', 'An error occurred while rotating the PDF.'))
|
||||
});
|
||||
};
|
160
frontend/src/hooks/tools/rotate/useRotateParameters.test.ts
Normal file
160
frontend/src/hooks/tools/rotate/useRotateParameters.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRotateParameters, defaultParameters, normalizeAngle } from './useRotateParameters';
|
||||
|
||||
describe('useRotateParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
expect(result.current.parameters).toEqual(defaultParameters);
|
||||
expect(result.current.parameters.angle).toBe(0);
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
});
|
||||
|
||||
test('should validate parameters correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
// Default should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Set invalid angle
|
||||
act(() => {
|
||||
result.current.updateParameter('angle', 45);
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
|
||||
// Set valid angle
|
||||
act(() => {
|
||||
result.current.updateParameter('angle', 90);
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
});
|
||||
|
||||
test('should rotate clockwise correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(90);
|
||||
expect(result.current.hasRotation).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(180);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(270);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(360);
|
||||
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
});
|
||||
|
||||
test('should rotate anticlockwise correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.rotateAnticlockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(-90);
|
||||
expect(result.current.hasRotation).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateAnticlockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(-180);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateAnticlockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(-270);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateAnticlockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(-360);
|
||||
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
});
|
||||
|
||||
test('should normalize angles correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
expect(result.current.normalizeAngle(360)).toBe(0);
|
||||
expect(result.current.normalizeAngle(450)).toBe(90);
|
||||
expect(result.current.normalizeAngle(-90)).toBe(270);
|
||||
expect(result.current.normalizeAngle(-180)).toBe(180);
|
||||
});
|
||||
|
||||
test('should reset parameters correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
// Set some rotation
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(90);
|
||||
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(180);
|
||||
|
||||
// Reset
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
expect(result.current.parameters).toEqual(defaultParameters);
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
});
|
||||
|
||||
test('should update parameters', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('angle', 450);
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(450);
|
||||
expect(normalizeAngle(result.current.parameters.angle)).toBe(90);
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('angle', -90);
|
||||
});
|
||||
expect(result.current.parameters.angle).toBe(-90);
|
||||
expect(normalizeAngle(result.current.parameters.angle)).toBe(270);
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('rotate-pdf');
|
||||
});
|
||||
|
||||
test('should detect rotation state correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
|
||||
// Initially no rotation
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
|
||||
// After rotation
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
expect(result.current.hasRotation).toBe(true);
|
||||
|
||||
// After full rotation (360 degrees) - 3 more clicks to complete 360°
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
});
|
||||
}
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
});
|
||||
});
|
67
frontend/src/hooks/tools/rotate/useRotateParameters.ts
Normal file
67
frontend/src/hooks/tools/rotate/useRotateParameters.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
|
||||
// Normalize angle to number between 0 and 359
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
return ((angle % 360) + 360) % 360;
|
||||
};
|
||||
|
||||
export interface RotateParameters extends BaseParameters {
|
||||
angle: number; // Current rotation angle (0, 90, 180, 270)
|
||||
}
|
||||
|
||||
export const defaultParameters: RotateParameters = {
|
||||
angle: 0,
|
||||
};
|
||||
|
||||
export type RotateParametersHook = BaseParametersHook<RotateParameters> & {
|
||||
rotateClockwise: () => void;
|
||||
rotateAnticlockwise: () => void;
|
||||
hasRotation: boolean;
|
||||
normalizeAngle: (angle: number) => number;
|
||||
};
|
||||
|
||||
export const useRotateParameters = (): RotateParametersHook => {
|
||||
const baseHook = useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'rotate-pdf',
|
||||
validateFn: (params) => {
|
||||
// Angle must be a multiple of 90
|
||||
return params.angle % 90 === 0;
|
||||
},
|
||||
});
|
||||
|
||||
// Rotate clockwise by 90 degrees
|
||||
const rotateClockwise = useCallback(() => {
|
||||
baseHook.updateParameter('angle', baseHook.parameters.angle + 90);
|
||||
}, [baseHook]);
|
||||
|
||||
// Rotate anticlockwise by 90 degrees
|
||||
const rotateAnticlockwise = useCallback(() => {
|
||||
baseHook.updateParameter('angle', baseHook.parameters.angle - 90);
|
||||
}, [baseHook]);
|
||||
|
||||
// Check if rotation will actually change the document
|
||||
const hasRotation = useMemo(() => {
|
||||
const normalized = normalizeAngle(baseHook.parameters.angle);
|
||||
return normalized !== 0;
|
||||
}, [baseHook.parameters.angle, normalizeAngle]);
|
||||
|
||||
// Override updateParameter - no longer normalize angles here
|
||||
const updateParameter = useCallback(<K extends keyof RotateParameters>(
|
||||
parameter: K,
|
||||
value: RotateParameters[K]
|
||||
) => {
|
||||
baseHook.updateParameter(parameter, value);
|
||||
}, [baseHook]);
|
||||
|
||||
return {
|
||||
...baseHook,
|
||||
updateParameter,
|
||||
rotateClockwise,
|
||||
rotateAnticlockwise,
|
||||
hasRotation,
|
||||
normalizeAngle,
|
||||
};
|
||||
};
|
123
frontend/src/hooks/tools/shared/useAccordionSteps.ts
Normal file
123
frontend/src/hooks/tools/shared/useAccordionSteps.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* State conditions that affect accordion behavior
|
||||
*/
|
||||
export interface AccordionStateConditions {
|
||||
/** Whether files are present (steps collapse when false) */
|
||||
hasFiles?: boolean;
|
||||
/** Whether results are available (steps collapse when true) */
|
||||
hasResults?: boolean;
|
||||
/** Whether the accordion is disabled (steps collapse when true) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the useAccordionSteps hook
|
||||
*/
|
||||
export interface UseAccordionStepsConfig<T extends string | number | symbol> {
|
||||
/** Special step that represents "no step open" state */
|
||||
noneValue: T;
|
||||
/** Initial step to open */
|
||||
initialStep: T;
|
||||
/** Current state conditions that affect accordion behavior */
|
||||
stateConditions?: AccordionStateConditions;
|
||||
/** Callback to run when interacting with a step when we have results (usually used for resetting params) */
|
||||
afterResults?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for the useAccordionSteps hook
|
||||
*/
|
||||
export interface AccordionStepsAPI<T extends string | number | symbol> {
|
||||
/** Currently active/open step (noneValue if no step is open) */
|
||||
currentStep: T;
|
||||
/** Get whether a specific step should be collapsed */
|
||||
getCollapsedState: (step: T) => boolean;
|
||||
/** Toggle a step open/closed (accordion behavior - only one open at a time) */
|
||||
handleStepToggle: (step: T) => void;
|
||||
/** Set the currently open step */
|
||||
setOpenStep: (step: T) => void;
|
||||
/** Close all steps */
|
||||
closeAllSteps: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion-style step management hook.
|
||||
*
|
||||
* Provides sophisticated accordion behavior where only one step can be open at a time,
|
||||
* with configurable collapse conditions.
|
||||
*/
|
||||
export function useAccordionSteps<T extends string | number | symbol>(
|
||||
config: UseAccordionStepsConfig<T>
|
||||
): AccordionStepsAPI<T> {
|
||||
const { initialStep, stateConditions, noneValue } = config;
|
||||
|
||||
const [openStep, setOpenStep] = useState<T>(initialStep);
|
||||
|
||||
// Determine if all steps should be collapsed based on conditions
|
||||
const shouldCollapseAll = useMemo(() => {
|
||||
if (!stateConditions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(stateConditions.hasFiles === false) ||
|
||||
(stateConditions.hasResults === true) ||
|
||||
(stateConditions.disabled === true)
|
||||
);
|
||||
}, [stateConditions]);
|
||||
|
||||
// Get collapsed state for a specific step
|
||||
const getCollapsedState = useCallback((step: T): boolean => {
|
||||
if (shouldCollapseAll) {
|
||||
return true;
|
||||
} else {
|
||||
return openStep !== step;
|
||||
}
|
||||
}, [openStep, shouldCollapseAll]);
|
||||
|
||||
// Handle step toggle with accordion behavior
|
||||
const handleStepToggle = useCallback((step: T) => {
|
||||
if (stateConditions?.hasResults) {
|
||||
config.afterResults?.();
|
||||
}
|
||||
|
||||
// If all steps should be collapsed, don't allow opening
|
||||
if (shouldCollapseAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Accordion behavior: if clicking the open step, close it; otherwise open the clicked step
|
||||
setOpenStep(currentStep => {
|
||||
if (currentStep === step) {
|
||||
// Clicking the open step - close it
|
||||
return noneValue;
|
||||
} else {
|
||||
// Open the clicked step
|
||||
return step;
|
||||
}
|
||||
});
|
||||
}, [shouldCollapseAll, noneValue, stateConditions?.hasResults, config.afterResults]);
|
||||
|
||||
// Close all steps
|
||||
const closeAllSteps = useCallback(() => {
|
||||
setOpenStep(noneValue);
|
||||
}, [noneValue]);
|
||||
|
||||
// Automatically reset to first step if we have results
|
||||
// Note that everything is still collapsed when this happens, it's just preparing for re-running the tool
|
||||
useEffect(() => {
|
||||
if (stateConditions?.hasResults) {
|
||||
setOpenStep(initialStep);
|
||||
}
|
||||
}, [stateConditions?.hasResults, initialStep]);
|
||||
|
||||
return {
|
||||
currentStep: shouldCollapseAll ? noneValue : openStep,
|
||||
getCollapsedState,
|
||||
handleStepToggle,
|
||||
setOpenStep,
|
||||
closeAllSteps
|
||||
};
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFileSelection } from '../../../contexts/FileContext';
|
||||
import { useEndpointEnabled } from '../../useEndpointConfig';
|
||||
import { BaseToolProps } from '../../../types/tool';
|
||||
@ -45,6 +45,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
|
||||
// File selection
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const previousFileCount = useRef(selectedFiles.length);
|
||||
|
||||
// Tool-specific hooks
|
||||
const params = useParams();
|
||||
@ -67,6 +68,18 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
}
|
||||
}, [selectedFiles.length]);
|
||||
|
||||
// Reset parameters when transitioning from 0 files to at least 1 file
|
||||
useEffect(() => {
|
||||
const currentFileCount = selectedFiles.length;
|
||||
const prevFileCount = previousFileCount.current;
|
||||
|
||||
if (prevFileCount === 0 && currentFileCount > 0) {
|
||||
params.resetParameters();
|
||||
}
|
||||
|
||||
previousFileCount.current = currentFileCount;
|
||||
}, [selectedFiles.length]);
|
||||
|
||||
// Standard handlers
|
||||
const handleExecute = useCallback(async () => {
|
||||
try {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '../vite-env.d.ts';
|
||||
import './index.css'; // Import Tailwind CSS
|
||||
import React from 'react';
|
||||
|
181
frontend/src/services/pdfMetadataService.ts
Normal file
181
frontend/src/services/pdfMetadataService.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { FileAnalyzer } from './fileAnalyzer';
|
||||
import { TrappedStatus, CustomMetadataEntry, ExtractedPDFMetadata } from '../types/metadata';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||
|
||||
export interface MetadataExtractionResult {
|
||||
success: true;
|
||||
metadata: ExtractedPDFMetadata;
|
||||
}
|
||||
|
||||
export interface MetadataExtractionError {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type MetadataExtractionResponse = MetadataExtractionResult | MetadataExtractionError;
|
||||
|
||||
/**
|
||||
* Utility to format PDF date strings to required format (yyyy/MM/dd HH:mm:ss)
|
||||
* Handles PDF date format: "D:YYYYMMDDHHmmSSOHH'mm'" or standard date strings
|
||||
*/
|
||||
function formatPDFDate(dateString: string): string {
|
||||
if (!dateString) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let date: Date;
|
||||
|
||||
// Check if it's a PDF date format (starts with "D:")
|
||||
if (dateString.startsWith('D:')) {
|
||||
// Parse PDF date format: D:YYYYMMDDHHmmSSOHH'mm'
|
||||
const dateStr = dateString.substring(2); // Remove "D:"
|
||||
|
||||
// Extract date parts
|
||||
const year = parseInt(dateStr.substring(0, 4));
|
||||
const month = parseInt(dateStr.substring(4, 6));
|
||||
const day = parseInt(dateStr.substring(6, 8));
|
||||
const hour = parseInt(dateStr.substring(8, 10)) || 0;
|
||||
const minute = parseInt(dateStr.substring(10, 12)) || 0;
|
||||
const second = parseInt(dateStr.substring(12, 14)) || 0;
|
||||
|
||||
// Create date object (month is 0-indexed)
|
||||
date = new Date(year, month - 1, day, hour, minute, second);
|
||||
} else {
|
||||
// Try parsing as regular date string
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PDF.js trapped value to TrappedStatus enum
|
||||
* PDF.js returns trapped as { name: "True" | "False" } object
|
||||
*/
|
||||
function convertTrappedStatus(trapped: unknown): TrappedStatus {
|
||||
if (trapped && typeof trapped === 'object' && 'name' in trapped) {
|
||||
const name = (trapped as Record<string, string>).name?.toLowerCase();
|
||||
if (name === 'true') return TrappedStatus.TRUE;
|
||||
if (name === 'false') return TrappedStatus.FALSE;
|
||||
}
|
||||
return TrappedStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract custom metadata fields from PDF.js info object
|
||||
* Custom metadata is nested under the "Custom" key
|
||||
*/
|
||||
function extractCustomMetadata(custom: unknown): CustomMetadataEntry[] {
|
||||
const customMetadata: CustomMetadataEntry[] = [];
|
||||
let customIdCounter = 1;
|
||||
|
||||
|
||||
// Check if there's a Custom object containing the custom metadata
|
||||
if (typeof custom === 'object' && custom !== null) {
|
||||
const customObj = custom as Record<string, unknown>;
|
||||
|
||||
Object.entries(customObj).forEach(([key, value]) => {
|
||||
if (value != null && value !== '') {
|
||||
const entry = {
|
||||
key,
|
||||
value: String(value),
|
||||
id: `custom${customIdCounter++}`
|
||||
};
|
||||
customMetadata.push(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return customMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely cleanup PDF document with error handling
|
||||
*/
|
||||
function cleanupPdfDocument(pdfDoc: PDFDocumentProxy | null): void {
|
||||
if (pdfDoc) {
|
||||
try {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup PDF document:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStringMetadata(info: Record<string, unknown>, key: string): string {
|
||||
if (typeof info[key] === 'string') {
|
||||
return info[key];
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all metadata from a PDF file
|
||||
* Returns a result object with success/error state
|
||||
*/
|
||||
export async function extractPDFMetadata(file: File): Promise<MetadataExtractionResponse> {
|
||||
// Use existing PDF validation
|
||||
const isValidPDF = await FileAnalyzer.isValidPDF(file);
|
||||
if (!isValidPDF) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'File is not a valid PDF'
|
||||
};
|
||||
}
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
let metadata;
|
||||
|
||||
try {
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
});
|
||||
metadata = await pdfDoc.getMetadata();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
cleanupPdfDocument(pdfDoc);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read PDF: ${errorMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const info = metadata.info as Record<string, unknown>;
|
||||
|
||||
// Safely extract metadata with proper type checking
|
||||
const extractedMetadata: ExtractedPDFMetadata = {
|
||||
title: getStringMetadata(info, 'Title'),
|
||||
author: getStringMetadata(info, 'Author'),
|
||||
subject: getStringMetadata(info, 'Subject'),
|
||||
keywords: getStringMetadata(info, 'Keywords'),
|
||||
creator: getStringMetadata(info, 'Creator'),
|
||||
producer: getStringMetadata(info, 'Producer'),
|
||||
creationDate: formatPDFDate(getStringMetadata(info, 'CreationDate')),
|
||||
modificationDate: formatPDFDate(getStringMetadata(info, 'ModDate')),
|
||||
trapped: convertTrappedStatus(info.Trapped),
|
||||
customMetadata: extractCustomMetadata(info.Custom),
|
||||
};
|
||||
|
||||
cleanupPdfDocument(pdfDoc);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: extractedMetadata
|
||||
};
|
||||
}
|
@ -6,11 +6,12 @@
|
||||
*/
|
||||
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
|
||||
class PDFWorkerManager {
|
||||
private static instance: PDFWorkerManager;
|
||||
private activeDocuments = new Set<any>();
|
||||
private activeDocuments = new Set<PDFDocumentProxy>();
|
||||
private workerCount = 0;
|
||||
private maxWorkers = 10; // Limit concurrent workers
|
||||
private isInitialized = false;
|
||||
@ -48,7 +49,7 @@ class PDFWorkerManager {
|
||||
stopAtErrors?: boolean;
|
||||
verbosity?: number;
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
): Promise<PDFDocumentProxy> {
|
||||
// Wait if we've hit the worker limit
|
||||
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||
await this.waitForAvailableWorker();
|
||||
@ -104,7 +105,7 @@ class PDFWorkerManager {
|
||||
/**
|
||||
* Properly destroy a PDF document and clean up resources
|
||||
*/
|
||||
destroyDocument(pdf: any): void {
|
||||
destroyDocument(pdf: PDFDocumentProxy): void {
|
||||
if (this.activeDocuments.has(pdf)) {
|
||||
try {
|
||||
pdf.destroy();
|
||||
|
@ -181,6 +181,11 @@
|
||||
|
||||
--information-text-bg: #eaeaea;
|
||||
--information-text-color: #5e5e5e;
|
||||
/* Bulk selection panel specific colors (light mode) */
|
||||
--bulk-panel-bg: #ffffff; /* white background for parent container */
|
||||
--bulk-card-bg: #ffffff; /* white background for cards */
|
||||
--bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */
|
||||
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
@ -327,6 +332,13 @@
|
||||
|
||||
--information-text-bg: #292e34;
|
||||
--information-text-color: #ececec;
|
||||
|
||||
/* Bulk selection panel specific colors (dark mode) */
|
||||
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
|
||||
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
|
||||
--bulk-card-border: var(--border-default); /* default border for cards and buttons */
|
||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||
|
||||
}
|
||||
|
||||
/* Dropzone drop state styling */
|
||||
|
156
frontend/src/tools/ChangeMetadata.tsx
Normal file
156
frontend/src/tools/ChangeMetadata.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
import DeleteAllStep from "../components/tools/changeMetadata/steps/DeleteAllStep";
|
||||
import StandardMetadataStep from "../components/tools/changeMetadata/steps/StandardMetadataStep";
|
||||
import DocumentDatesStep from "../components/tools/changeMetadata/steps/DocumentDatesStep";
|
||||
import AdvancedOptionsStep from "../components/tools/changeMetadata/steps/AdvancedOptionsStep";
|
||||
import { useChangeMetadataParameters } from "../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { useChangeMetadataOperation } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { useMetadataExtraction } from "../hooks/tools/changeMetadata/useMetadataExtraction";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import {
|
||||
useDeleteAllTips,
|
||||
useStandardMetadataTips,
|
||||
useDocumentDatesTips,
|
||||
useAdvancedOptionsTips
|
||||
} from "../components/tooltips/useChangeMetadataTips";
|
||||
|
||||
enum MetadataStep {
|
||||
NONE = 'none',
|
||||
DELETE_ALL = 'deleteAll',
|
||||
STANDARD_METADATA = 'standardMetadata',
|
||||
DOCUMENT_DATES = 'documentDates',
|
||||
ADVANCED_OPTIONS = 'advancedOptions'
|
||||
}
|
||||
|
||||
const ChangeMetadata = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Individual tooltips for each step
|
||||
const deleteAllTips = useDeleteAllTips();
|
||||
const standardMetadataTips = useStandardMetadataTips();
|
||||
const documentDatesTips = useDocumentDatesTips();
|
||||
const advancedOptionsTips = useAdvancedOptionsTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'changeMetadata',
|
||||
useChangeMetadataParameters,
|
||||
useChangeMetadataOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
// Extract metadata from uploaded files
|
||||
const { isExtractingMetadata } = useMetadataExtraction(base.params);
|
||||
|
||||
// Accordion step management
|
||||
const accordion = useAccordionSteps<MetadataStep>({
|
||||
noneValue: MetadataStep.NONE,
|
||||
initialStep: MetadataStep.DELETE_ALL,
|
||||
stateConditions: {
|
||||
hasFiles: base.hasFiles,
|
||||
hasResults: base.hasResults
|
||||
},
|
||||
afterResults: base.handleSettingsReset,
|
||||
});
|
||||
|
||||
// Create step objects
|
||||
const createStandardMetadataStep = () => ({
|
||||
title: t("changeMetadata.standardFields.title", "Standard Fields"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.STANDARD_METADATA),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.STANDARD_METADATA),
|
||||
tooltip: standardMetadataTips,
|
||||
content: (
|
||||
<StandardMetadataStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const createDocumentDatesStep = () => ({
|
||||
title: t("changeMetadata.dates.title", "Date Fields"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.DOCUMENT_DATES),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.DOCUMENT_DATES),
|
||||
tooltip: documentDatesTips,
|
||||
content: (
|
||||
<DocumentDatesStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const createAdvancedOptionsStep = () => ({
|
||||
title: t("changeMetadata.advanced.title", "Advanced Options"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.ADVANCED_OPTIONS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.ADVANCED_OPTIONS),
|
||||
tooltip: advancedOptionsTips,
|
||||
content: (
|
||||
<AdvancedOptionsStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
addCustomMetadata={base.params.addCustomMetadata}
|
||||
removeCustomMetadata={base.params.removeCustomMetadata}
|
||||
updateCustomMetadata={base.params.updateCustomMetadata}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Build steps array based on deleteAll state
|
||||
const buildSteps = () => {
|
||||
const steps = [
|
||||
{
|
||||
title: t("changeMetadata.deleteAll.label", "Remove Existing Metadata"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.DELETE_ALL),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.DELETE_ALL),
|
||||
tooltip: deleteAllTips,
|
||||
content: (
|
||||
<DeleteAllStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!base.params.parameters.deleteAll) {
|
||||
steps.push(
|
||||
createStandardMetadataStep(),
|
||||
createDocumentDatesStep(),
|
||||
createAdvancedOptionsStep()
|
||||
);
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: buildSteps(),
|
||||
executeButton: {
|
||||
text: t("changeMetadata.submit", "Update Metadata"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("changeMetadata.results.title", "Updated PDFs"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ChangeMetadata as ToolComponent;
|
@ -16,7 +16,7 @@ const Merge = (props: BaseToolProps) => {
|
||||
|
||||
// File selection hooks for custom sorting
|
||||
const { fileIds } = useAllFiles();
|
||||
const { selectedRecords } = useSelectedFiles();
|
||||
const { selectedFileStubs } = useSelectedFiles();
|
||||
const { reorderFiles } = useFileManagement();
|
||||
|
||||
const base = useBaseTool(
|
||||
@ -29,23 +29,23 @@ const Merge = (props: BaseToolProps) => {
|
||||
|
||||
// Custom file sorting logic for merge tool
|
||||
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
|
||||
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
|
||||
const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => {
|
||||
let comparison = 0;
|
||||
switch (sortType) {
|
||||
case 'filename':
|
||||
comparison = recordA.name.localeCompare(recordB.name);
|
||||
comparison = stubA.name.localeCompare(stubB.name);
|
||||
break;
|
||||
case 'dateModified':
|
||||
comparison = recordA.lastModified - recordB.lastModified;
|
||||
comparison = stubA.lastModified - stubB.lastModified;
|
||||
break;
|
||||
}
|
||||
return ascending ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const selectedIds = sortedRecords.map(record => record.id);
|
||||
const selectedIds = sortedStubs.map(record => record.id);
|
||||
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
|
||||
reorderFiles([...selectedIds, ...deselectedIds]);
|
||||
}, [selectedRecords, fileIds, reorderFiles]);
|
||||
}, [selectedFileStubs, fileIds, reorderFiles]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
|
70
frontend/src/tools/RemoveBlanks.tsx
Normal file
70
frontend/src/tools/RemoveBlanks.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { useRemoveBlanksParameters } from "../hooks/tools/removeBlanks/useRemoveBlanksParameters";
|
||||
import { useRemoveBlanksOperation } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
|
||||
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
|
||||
import { useRemoveBlanksTips } from "../components/tooltips/useRemoveBlanksTips";
|
||||
|
||||
const RemoveBlanks = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const tooltipContent = useRemoveBlanksTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'remove-blanks',
|
||||
useRemoveBlanksParameters,
|
||||
useRemoveBlanksOperation,
|
||||
props
|
||||
);
|
||||
|
||||
const settingsContent = (
|
||||
<RemoveBlanksSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
if (base.hasResults) {
|
||||
base.handleSettingsReset();
|
||||
}
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("removeBlanks.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: handleSettingsClick,
|
||||
content: settingsContent,
|
||||
tooltip: tooltipContent,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("removeBlanks.submit", "Remove blank pages"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removeBlanks.results.title", "Removed Blank Pages"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
RemoveBlanks.tool = () => useRemoveBlanksOperation;
|
||||
|
||||
export default RemoveBlanks as ToolComponent;
|
||||
|
||||
|
64
frontend/src/tools/RemovePages.tsx
Normal file
64
frontend/src/tools/RemovePages.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { useRemovePagesParameters } from "../hooks/tools/removePages/useRemovePagesParameters";
|
||||
import { useRemovePagesOperation } from "../hooks/tools/removePages/useRemovePagesOperation";
|
||||
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
|
||||
import { useRemovePagesTips } from "../components/tooltips/useRemovePagesTips";
|
||||
|
||||
const RemovePages = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const tooltipContent = useRemovePagesTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'remove-pages',
|
||||
useRemovePagesParameters,
|
||||
useRemovePagesOperation,
|
||||
props
|
||||
);
|
||||
|
||||
|
||||
const settingsContent = (
|
||||
<RemovePagesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("removePages.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
content: settingsContent,
|
||||
tooltip: tooltipContent,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("removePages.submit", "Remove Pages"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removePages.results.title", "Pages Removed"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
RemovePages.tool = () => useRemovePagesOperation;
|
||||
|
||||
export default RemovePages as ToolComponent;
|
57
frontend/src/tools/Rotate.tsx
Normal file
57
frontend/src/tools/Rotate.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import { useRotateParameters } from "../hooks/tools/rotate/useRotateParameters";
|
||||
import { useRotateOperation } from "../hooks/tools/rotate/useRotateOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useRotateTips } from "../components/tooltips/useRotateTips";
|
||||
|
||||
const Rotate = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const rotateTips = useRotateTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'rotate',
|
||||
useRotateParameters,
|
||||
useRotateOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: rotateTips,
|
||||
content: (
|
||||
<RotateSettings
|
||||
parameters={base.params}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("rotate.submit", "Apply Rotation"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("rotate.title", "Rotation Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Rotate as ToolComponent;
|
24
frontend/src/types/metadata.ts
Normal file
24
frontend/src/types/metadata.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export enum TrappedStatus {
|
||||
TRUE = 'True',
|
||||
FALSE = 'False',
|
||||
UNKNOWN = 'Unknown'
|
||||
}
|
||||
|
||||
export interface CustomMetadataEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
id: string; // For React uniqueness
|
||||
}
|
||||
|
||||
export interface ExtractedPDFMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string;
|
||||
creator: string;
|
||||
producer: string;
|
||||
creationDate: string;
|
||||
modificationDate: string;
|
||||
trapped: TrappedStatus;
|
||||
customMetadata: CustomMetadataEntry[];
|
||||
}
|
77
frontend/src/utils/bulkselection/README.md
Normal file
77
frontend/src/utils/bulkselection/README.md
Normal file
@ -0,0 +1,77 @@
|
||||
## Bulk Selection Expressions
|
||||
|
||||
### What this does
|
||||
|
||||
- Lets you select pages using compact expressions instead of typing long CSV lists.
|
||||
- Your input expression is preserved exactly as typed; we only expand it under the hood into concrete page numbers based on the current document's page count.
|
||||
- The final selection is always deduplicated, clamped to valid page numbers, and sorted ascending.
|
||||
|
||||
### Basic forms
|
||||
|
||||
- Numbers: `5` selects page 5.
|
||||
- Ranges: `3-7` selects pages 3,4,5,6,7 (inclusive). If the start is greater than the end, it is swapped automatically (e.g., `7-3` → `3-7`).
|
||||
- Lists (OR): `1,3-5,10` selects pages 1,3,4,5,10.
|
||||
|
||||
You can still use the original CSV format. For example, `1,2,3,4,5` (first five pages) continues to work.
|
||||
|
||||
### Logical operators
|
||||
|
||||
- OR (union): `,` or `|` or the word `or`
|
||||
- AND (intersection): `&` or the word `and`
|
||||
- NOT (complement within 1..max): `!term` or `!(group)` or the word `not term` / `not (group)`
|
||||
|
||||
Operator precedence (from highest to lowest):
|
||||
1) `!` (NOT)
|
||||
2) `&` / `and` (AND)
|
||||
3) `,` / `|` / `or` (OR)
|
||||
|
||||
Use parentheses `(...)` to override precedence where needed.
|
||||
|
||||
### Keywords and progressions
|
||||
|
||||
- Keywords (case-insensitive):
|
||||
- `even`: all even pages (2, 4, 6, ...)
|
||||
- `odd`: all odd pages (1, 3, 5, ...)
|
||||
|
||||
- Arithmetic progressions: `k n ± c`, e.g. `2n`, `3n+1`, `4n-1`
|
||||
- `n` starts at 0 (CSS-style: `:nth-child`), then increases by 1 (n = 0,1,2,...). Non-positive results are discarded.
|
||||
- `k` must be a positive integer (≥ 1). `c` can be any integer (including negative).
|
||||
- Examples:
|
||||
- `2n` → 0,2,4,6,... → becomes 2,4,6,... after discarding non-positive
|
||||
- `2n-1` → -1,1,3,5,... → becomes 1,3,5,... (odd)
|
||||
- `3n+1` → 1,4,7,10,13,...
|
||||
|
||||
All selections are automatically limited to the current document's valid page numbers `[1..maxPages]`.
|
||||
|
||||
### Parentheses
|
||||
|
||||
- Group with parentheses to control evaluation order and combine NOT with groups.
|
||||
- Examples:
|
||||
- `1-10 & (even, 15)` → even pages 2,4,6,8,10 (15 is outside 1-10)
|
||||
- `!(1-5, odd)` → remove pages 1..5 and all odd pages; for a 10-page doc this yields 6,8,10
|
||||
- `!(10-20 & !2n)` → complement of odd pages from 11..19 inside 10..20
|
||||
- `(2n | 3n+1) & 1-20` → union of even numbers and 3n+1 numbers, intersected with 1..20
|
||||
|
||||
### Whitespace and case
|
||||
|
||||
- Whitespace is ignored: ` odd & 1 - 7` is valid.
|
||||
- Keywords are case-insensitive: `ODD`, `Odd`, `odd` all work.
|
||||
|
||||
### Universe, clamping, deduplication
|
||||
|
||||
- The selection universe is the document's pages `[1..maxPages]`.
|
||||
- Numbers outside the universe are discarded.
|
||||
- Ranges are clamped to `[1..maxPages]` (e.g., `0-5` → `1-5`, `9-999` in a 10-page doc → `9-10`).
|
||||
- Duplicates are removed; the final result is sorted ascending.
|
||||
|
||||
### Examples
|
||||
|
||||
- `1-10 & 2n & !5-7` → 2,4,8,10
|
||||
- `odd` → 1,3,5,7,9,...
|
||||
- `even` → 2,4,6,8,10,...
|
||||
- `2n-1` → 1,3,5,7,9,...
|
||||
- `3n+1` → 4,7,10,13,16,... (up to max pages)
|
||||
- `1-3, 8-9` → 1,2,3,8,9
|
||||
- `1-2 | 9-10 or 5` → 1,2,5,9,10
|
||||
- `!(1-5)` → remove the first five pages from the universe
|
||||
- `!(10-20 & !2n)` → complement of odd pages between 10 and 20
|
253
frontend/src/utils/bulkselection/parseSelection.test.ts
Normal file
253
frontend/src/utils/bulkselection/parseSelection.test.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSelection } from './parseSelection';
|
||||
|
||||
describe('parseSelection', () => {
|
||||
const max = 120;
|
||||
|
||||
it('1) parses single numbers', () => {
|
||||
expect(parseSelection('5', max)).toEqual([5]);
|
||||
});
|
||||
|
||||
it('2) parses simple range', () => {
|
||||
expect(parseSelection('3-7', max)).toEqual([3,4,5,6,7]);
|
||||
});
|
||||
|
||||
it('3) parses multiple numbers and ranges via comma OR', () => {
|
||||
expect(parseSelection('1,3-5,10', max)).toEqual([1,3,4,5,10]);
|
||||
});
|
||||
|
||||
it('4) respects bounds (clamps to 1..max and filters invalid)', () => {
|
||||
expect(parseSelection('0, -2, 1-2, 9999', max)).toEqual([1,2]);
|
||||
});
|
||||
|
||||
it('5) supports even keyword', () => {
|
||||
expect(parseSelection('even', 10)).toEqual([2,4,6,8,10]);
|
||||
});
|
||||
|
||||
it('6) supports odd keyword', () => {
|
||||
expect(parseSelection('odd', 10)).toEqual([1,3,5,7,9]);
|
||||
});
|
||||
|
||||
it('7) supports 2n progression', () => {
|
||||
expect(parseSelection('2n', 12)).toEqual([2,4,6,8,10,12]);
|
||||
});
|
||||
|
||||
it('8) supports kn±c progression (3n+1)', () => {
|
||||
expect(parseSelection('3n+1', 10)).toEqual([1,4,7,10]);
|
||||
});
|
||||
|
||||
it('9) supports kn±c progression (4n-1)', () => {
|
||||
expect(parseSelection('4n-1', 15)).toEqual([3,7,11,15]);
|
||||
});
|
||||
|
||||
it('10) supports logical AND (&) intersection', () => {
|
||||
// even AND 1-10 => even numbers within 1..10
|
||||
expect(parseSelection('even & 1-10', 20)).toEqual([2,4,6,8,10]);
|
||||
});
|
||||
|
||||
it('11) supports logical OR with comma', () => {
|
||||
expect(parseSelection('1-3, 8-9', 20)).toEqual([1,2,3,8,9]);
|
||||
});
|
||||
|
||||
it('12) supports logical OR with | and word or', () => {
|
||||
expect(parseSelection('1-2 | 9-10 or 5', 20)).toEqual([1,2,5,9,10]);
|
||||
});
|
||||
|
||||
it('13) supports NOT operator !', () => {
|
||||
// !1-5 within max=10 -> 6..10
|
||||
expect(parseSelection('!1-5', 10)).toEqual([6,7,8,9,10]);
|
||||
});
|
||||
|
||||
it('14) supports combination: 1-10 & 2n & !5-7', () => {
|
||||
expect(parseSelection('1-10 & 2n & !5-7', 20)).toEqual([2,4,8,10]);
|
||||
});
|
||||
|
||||
it('15) preserves precedence: AND over OR', () => {
|
||||
// 1-10 & even, 15 OR => ( (1-10 & even) , 15 )
|
||||
expect(parseSelection('1-10 & even, 15', 20)).toEqual([2,4,6,8,10,15]);
|
||||
});
|
||||
|
||||
it('16) handles whitespace and case-insensitive keywords', () => {
|
||||
expect(parseSelection(' OdD & 1-7 ', 10)).toEqual([1,3,5,7]);
|
||||
});
|
||||
|
||||
it('17) progression plus range: 2n | 9-11 within 12', () => {
|
||||
expect(parseSelection('2n | 9-11', 12)).toEqual([2,4,6,8,9,10,11,12]);
|
||||
});
|
||||
|
||||
it('18) complex: (2n-1 & 1-20) & ! (5-7)', () => {
|
||||
expect(parseSelection('2n-1 & 1-20 & !5-7', 20)).toEqual([1,3,9,11,13,15,17,19]);
|
||||
});
|
||||
|
||||
it('19) falls back to CSV when expression malformed', () => {
|
||||
// malformed: "2x" -> fallback should treat as CSV tokens -> only 2 ignored -> result empty
|
||||
expect(parseSelection('2x', 10)).toEqual([]);
|
||||
// malformed middle; still fallback handles CSV bits
|
||||
expect(parseSelection('1, 3-5, foo, 9', 10)).toEqual([1,3,4,5,9]);
|
||||
});
|
||||
|
||||
it('20) clamps ranges that exceed bounds', () => {
|
||||
expect(parseSelection('0-5, 9-10', 10)).toEqual([1,2,3,4,5,9,10]);
|
||||
});
|
||||
|
||||
it('21) supports parentheses to override precedence', () => {
|
||||
// Without parentheses: 1-10 & even, 15 => [2,4,6,8,10,15]
|
||||
// With parentheses around OR: 1-10 & (even, 15) => [2,4,6,8,10]
|
||||
expect(parseSelection('1-10 & (even, 15)', 20)).toEqual([2,4,6,8,10]);
|
||||
});
|
||||
|
||||
it('22) NOT over a grouped intersection', () => {
|
||||
// !(10-20 & !2n) within 1..25
|
||||
// Inner: 10-20 & !2n => odd numbers from 11..19 plus 10,12,14,16,18,20 excluded
|
||||
// Complement in 1..25 removes those, keeping others
|
||||
const result = parseSelection('!(10-20 & !2n)', 25);
|
||||
expect(result).toEqual([1,2,3,4,5,6,7,8,9,10,12,14,16,18,20,21,22,23,24,25]);
|
||||
});
|
||||
|
||||
it('23) nested parentheses with progressions', () => {
|
||||
expect(parseSelection('(2n | 3n+1) & 1-20', 50)).toEqual([
|
||||
1,2,4,6,7,8,10,12,13,14,16,18,19,20
|
||||
]);
|
||||
});
|
||||
|
||||
it('24) parentheses with NOT directly on group', () => {
|
||||
expect(parseSelection('!(1-5, odd)', 10)).toEqual([6,8,10]);
|
||||
});
|
||||
|
||||
it('25) whitespace within parentheses is ignored', () => {
|
||||
expect(parseSelection('( 1 - 3 , 6 )', 10)).toEqual([1,2,3,6]);
|
||||
});
|
||||
|
||||
it('26) malformed missing closing parenthesis falls back to CSV', () => {
|
||||
// Expression parse should fail; fallback CSV should pick numbers only
|
||||
expect(parseSelection('(1-3, 6', 10)).toEqual([6]);
|
||||
});
|
||||
|
||||
it('27) nested NOT and AND with parentheses', () => {
|
||||
// !(odd & 5-9) within 1..12 => remove odd numbers 5,7,9
|
||||
expect(parseSelection('!(odd & 5-9)', 12)).toEqual([1,2,3,4,6,8,10,11,12]);
|
||||
});
|
||||
|
||||
it('28) deep nesting and mixing operators', () => {
|
||||
const expr = '(1-4 & 2n) , ( (5-10 & odd) & !(7) ), (3n+1 & 1-20)';
|
||||
expect(parseSelection(expr, 20)).toEqual([1,2,4,5,7,9,10,13,16,19]);
|
||||
});
|
||||
|
||||
it('31) word NOT works like ! for terms', () => {
|
||||
expect(parseSelection('not 1-3', 6)).toEqual([4,5,6]);
|
||||
});
|
||||
|
||||
it('32) word NOT works like ! for groups', () => {
|
||||
expect(parseSelection('not (odd & 1-6)', 8)).toEqual([2,4,6,7,8]);
|
||||
});
|
||||
|
||||
it('29) parentheses around a single term has no effect', () => {
|
||||
expect(parseSelection('(even)', 8)).toEqual([2,4,6,8]);
|
||||
});
|
||||
|
||||
it('30) redundant nested parentheses', () => {
|
||||
expect(parseSelection('(((1-3))), ((2n))', 6)).toEqual([1,2,3,4,6]);
|
||||
});
|
||||
|
||||
// Additional edge cases and comprehensive coverage
|
||||
it('33) handles empty input gracefully', () => {
|
||||
expect(parseSelection('', 10)).toEqual([]);
|
||||
expect(parseSelection(' ', 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('34) handles zero or negative maxPages', () => {
|
||||
expect(parseSelection('1-10', 0)).toEqual([]);
|
||||
expect(parseSelection('1-10', -5)).toEqual([]);
|
||||
});
|
||||
|
||||
it('35) handles large progressions efficiently', () => {
|
||||
expect(parseSelection('100n', 1000)).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]);
|
||||
});
|
||||
|
||||
it('36) handles progressions with large offsets', () => {
|
||||
expect(parseSelection('5n+97', 100)).toEqual([97]);
|
||||
expect(parseSelection('3n-2', 10)).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
|
||||
it('37) mixed case keywords work correctly', () => {
|
||||
expect(parseSelection('EVEN & Odd', 6)).toEqual([]);
|
||||
expect(parseSelection('Even OR odd', 6)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it('38) complex nested expressions with all operators', () => {
|
||||
const expr = '(1-20 & even) | (odd & !5-15) | (3n+1 & 1-10)';
|
||||
// (1-20 & even) = [2,4,6,8,10,12,14,16,18,20]
|
||||
// (odd & !5-15) = odd numbers not in 5-15 = [1,3,17,19]
|
||||
// (3n+1 & 1-10) = [1,4,7,10]
|
||||
// Union of all = [1,2,3,4,6,7,8,10,12,14,16,17,18,19,20]
|
||||
expect(parseSelection(expr, 20)).toEqual([1, 2, 3, 4, 6, 7, 8, 10, 12, 14, 16, 17, 18, 19, 20]);
|
||||
});
|
||||
|
||||
it('39) multiple NOT operators in sequence', () => {
|
||||
expect(parseSelection('not not 1-5', 10)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(parseSelection('!!!1-3', 10)).toEqual([4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it('40) edge case: single page selection', () => {
|
||||
expect(parseSelection('1', 1)).toEqual([1]);
|
||||
expect(parseSelection('5', 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it('41) backwards ranges are handled correctly', () => {
|
||||
expect(parseSelection('10-5', 15)).toEqual([5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it('42) progressions that start beyond maxPages', () => {
|
||||
expect(parseSelection('10n+50', 40)).toEqual([]);
|
||||
expect(parseSelection('5n+35', 40)).toEqual([35, 40]);
|
||||
});
|
||||
|
||||
it('43) complex operator precedence with mixed syntax', () => {
|
||||
// AND has higher precedence than OR
|
||||
expect(parseSelection('1-3, 5-7 & even', 10)).toEqual([1, 2, 3, 6]);
|
||||
expect(parseSelection('1-3 | 5-7 and even', 10)).toEqual([1, 2, 3, 6]);
|
||||
});
|
||||
|
||||
it('44) whitespace tolerance in complex expressions', () => {
|
||||
const expr1 = '1-5&even|odd&!3';
|
||||
const expr2 = ' 1 - 5 & even | odd & ! 3 ';
|
||||
expect(parseSelection(expr1, 10)).toEqual(parseSelection(expr2, 10));
|
||||
});
|
||||
|
||||
it('45) fallback behavior with partial valid expressions', () => {
|
||||
// Should fallback and extract valid CSV parts
|
||||
expect(parseSelection('1, 2-4, invalid, 7', 10)).toEqual([1, 2, 3, 4, 7]);
|
||||
expect(parseSelection('1-3, @#$, 8-9', 10)).toEqual([1, 2, 3, 8, 9]);
|
||||
});
|
||||
|
||||
it('46) progressions with k=1 (equivalent to n)', () => {
|
||||
expect(parseSelection('1n', 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(parseSelection('1n+2', 5)).toEqual([2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('47) very large ranges are clamped correctly', () => {
|
||||
expect(parseSelection('1-999999', 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
// Note: -100-5 would fallback to CSV and reject -100, but 0-5 should work
|
||||
expect(parseSelection('0-5', 10)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('48) multiple comma-separated ranges', () => {
|
||||
expect(parseSelection('1-2, 4-5, 7-8, 10', 10)).toEqual([1, 2, 4, 5, 7, 8, 10]);
|
||||
});
|
||||
|
||||
it('49) combination of all features in one expression', () => {
|
||||
const expr = '(1-10 & even) | (odd & 15-25) & !(3n+1 & 1-30) | 50n';
|
||||
const result = parseSelection(expr, 100);
|
||||
// This should combine: even numbers 2,4,6,8,10 with odd 15-25 excluding 3n+1 matches, plus 50n
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).toContain(50);
|
||||
expect(result).toContain(100);
|
||||
});
|
||||
|
||||
it('50) stress test with deeply nested parentheses', () => {
|
||||
const expr = '((((1-5)))) & ((((even)))) | ((((odd & 7-9))))';
|
||||
expect(parseSelection(expr, 10)).toEqual([2, 4, 7, 9]);
|
||||
});
|
||||
});
|
||||
|
||||
|
413
frontend/src/utils/bulkselection/parseSelection.ts
Normal file
413
frontend/src/utils/bulkselection/parseSelection.ts
Normal file
@ -0,0 +1,413 @@
|
||||
// A parser that converts selection expressions (e.g., "1-10 & 2n & !50-100", "odd", "2n-1")
|
||||
// into a list of page numbers within [1, maxPages].
|
||||
|
||||
/*
|
||||
Supported grammar (case-insensitive for words):
|
||||
expression := disjunction
|
||||
disjunction := conjunction ( ("," | "|" | "or") conjunction )*
|
||||
conjunction := unary ( ("&" | "and") unary )*
|
||||
unary := ("!" unary) | ("not" unary) | primary
|
||||
primary := "(" expression ")" | range | progression | keyword | number
|
||||
range := number "-" number // inclusive
|
||||
progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0
|
||||
keyword := "even" | "odd"
|
||||
number := digits (>= 1)
|
||||
|
||||
Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR)
|
||||
Associativity: left-to-right within the same precedence level
|
||||
|
||||
Notes:
|
||||
- Whitespace is ignored.
|
||||
- The universe is [1..maxPages]. The complement operator ("!" / "not") applies within this universe.
|
||||
- Out-of-bounds numbers are clamped in ranges and ignored as singletons.
|
||||
- On parse failure, the parser falls back to CSV (numbers and ranges separated by commas).
|
||||
|
||||
Examples:
|
||||
1-10 & even -> even pages between 1 and 10
|
||||
!(5-7) -> all pages except 5..7
|
||||
3n+1 -> 1,4,7,... (n starts at 0)
|
||||
(2n | 3n+1) & 1-20 -> multiples of 2 or numbers of the form 3n+1 within 1..20
|
||||
*/
|
||||
|
||||
export function parseSelection(input: string, maxPages: number): number[] {
|
||||
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
|
||||
if (clampedMax === 0) return [];
|
||||
|
||||
const trimmed = (input || '').trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
|
||||
try {
|
||||
const parser = new ExpressionParser(trimmed, clampedMax);
|
||||
const resultSet = parser.parse();
|
||||
return toSortedArray(resultSet);
|
||||
} catch {
|
||||
// Fallback: simple CSV parser (e.g., "1,3,5-10")
|
||||
return toSortedArray(parseCsvFallback(trimmed, clampedMax));
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSelectionWithDiagnostics(
|
||||
input: string,
|
||||
maxPages: number,
|
||||
options?: { strict?: boolean }
|
||||
): { pages: number[]; warning?: string } {
|
||||
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
|
||||
if (clampedMax === 0) return { pages: [] };
|
||||
|
||||
const trimmed = (input || '').trim();
|
||||
if (trimmed.length === 0) return { pages: [] };
|
||||
|
||||
try {
|
||||
const parser = new ExpressionParser(trimmed, clampedMax);
|
||||
const resultSet = parser.parse();
|
||||
return { pages: toSortedArray(resultSet) };
|
||||
} catch (err) {
|
||||
if (options?.strict) {
|
||||
throw err;
|
||||
}
|
||||
const pages = toSortedArray(parseCsvFallback(trimmed, clampedMax));
|
||||
const tokens = trimmed.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const bad = tokens.find(tok => !/^(\d+\s*-\s*\d+|\d+)$/.test(tok));
|
||||
const warning = `Malformed expression${bad ? ` at: '${bad}'` : ''}. Falling back to CSV interpretation.`;
|
||||
return { pages, warning };
|
||||
}
|
||||
}
|
||||
|
||||
function toSortedArray(set: Set<number>): number[] {
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function parseCsvFallback(input: string, max: number): Set<number> {
|
||||
const result = new Set<number>();
|
||||
const parts = input.split(',').map(p => p.trim()).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const start = clampToRange(parseInt(rangeMatch[1], 10), 1, max);
|
||||
const end = clampToRange(parseInt(rangeMatch[2], 10), 1, max);
|
||||
if (Number.isFinite(start) && Number.isFinite(end)) {
|
||||
const [lo, hi] = start <= end ? [start, end] : [end, start];
|
||||
for (let i = lo; i <= hi; i++) result.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Accept only pure positive integers (no signs, no letters)
|
||||
if (/^\d+$/.test(part)) {
|
||||
const n = parseInt(part, 10);
|
||||
if (Number.isFinite(n) && n >= 1 && n <= max) result.add(n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function clampToRange(v: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(v)) return NaN as unknown as number;
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
class ExpressionParser {
|
||||
private readonly src: string;
|
||||
private readonly max: number;
|
||||
private idx: number = 0;
|
||||
|
||||
constructor(source: string, maxPages: number) {
|
||||
this.src = source;
|
||||
this.max = maxPages;
|
||||
}
|
||||
|
||||
parse(): Set<number> {
|
||||
this.skipWs();
|
||||
const set = this.parseDisjunction();
|
||||
this.skipWs();
|
||||
// If there are leftover non-space characters, treat as error
|
||||
if (this.idx < this.src.length) {
|
||||
throw new Error('Unexpected trailing input');
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private parseDisjunction(): Set<number> {
|
||||
let left = this.parseConjunction();
|
||||
while (true) {
|
||||
this.skipWs();
|
||||
const op = this.peekWordOrSymbol();
|
||||
if (!op) break;
|
||||
if (op.type === 'symbol' && (op.value === ',' || op.value === '|')) {
|
||||
this.consume(op.length);
|
||||
const right = this.parseConjunction();
|
||||
left = union(left, right);
|
||||
continue;
|
||||
}
|
||||
if (op.type === 'word' && op.value === 'or') {
|
||||
this.consume(op.length);
|
||||
const right = this.parseConjunction();
|
||||
left = union(left, right);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseConjunction(): Set<number> {
|
||||
let left = this.parseUnary();
|
||||
while (true) {
|
||||
this.skipWs();
|
||||
const op = this.peekWordOrSymbol();
|
||||
if (!op) break;
|
||||
if (op.type === 'symbol' && op.value === '&') {
|
||||
this.consume(op.length);
|
||||
const right = this.parseUnary();
|
||||
left = intersect(left, right);
|
||||
continue;
|
||||
}
|
||||
if (op.type === 'word' && op.value === 'and') {
|
||||
this.consume(op.length);
|
||||
const right = this.parseUnary();
|
||||
left = intersect(left, right);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseUnary(): Set<number> {
|
||||
this.skipWs();
|
||||
if (this.peek('!')) {
|
||||
this.consume(1);
|
||||
const inner = this.parseUnary();
|
||||
return complement(inner, this.max);
|
||||
}
|
||||
// Word-form NOT
|
||||
if (this.tryConsumeNot()) {
|
||||
const inner = this.parseUnary();
|
||||
return complement(inner, this.max);
|
||||
}
|
||||
return this.parsePrimary();
|
||||
}
|
||||
|
||||
private parsePrimary(): Set<number> {
|
||||
this.skipWs();
|
||||
|
||||
// Parenthesized expression: '(' expression ')'
|
||||
if (this.peek('(')) {
|
||||
this.consume(1);
|
||||
const inner = this.parseDisjunction();
|
||||
this.skipWs();
|
||||
if (!this.peek(')')) throw new Error('Expected )');
|
||||
this.consume(1);
|
||||
return inner;
|
||||
}
|
||||
|
||||
// Keywords: even / odd
|
||||
const keyword = this.tryReadKeyword();
|
||||
if (keyword) {
|
||||
if (keyword === 'even') return this.buildEven();
|
||||
if (keyword === 'odd') return this.buildOdd();
|
||||
}
|
||||
|
||||
// Progression: k n ( +/- c )?
|
||||
const progression = this.tryReadProgression();
|
||||
if (progression) {
|
||||
return this.buildProgression(progression.k, progression.c);
|
||||
}
|
||||
|
||||
// Number or Range
|
||||
const num = this.tryReadNumber();
|
||||
if (num !== null) {
|
||||
this.skipWs();
|
||||
if (this.peek('-')) {
|
||||
// Range
|
||||
this.consume(1);
|
||||
this.skipWs();
|
||||
const end = this.readRequiredNumber();
|
||||
return this.buildRange(num, end);
|
||||
}
|
||||
return this.buildSingleton(num);
|
||||
}
|
||||
|
||||
// If nothing matched, error
|
||||
throw new Error('Expected primary');
|
||||
}
|
||||
|
||||
private buildSingleton(n: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
if (n >= 1 && n <= this.max) set.add(n);
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildRange(a: number, b: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
let start = a, end = b;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return set;
|
||||
if (start > end) [start, end] = [end, start];
|
||||
start = Math.max(1, start);
|
||||
end = Math.min(this.max, end);
|
||||
for (let i = start; i <= end; i++) set.add(i);
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildProgression(k: number, c: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
if (!(k >= 1)) return set;
|
||||
// n starts at 0: k*n + c, for n=0,1,2,... while within [1..max]
|
||||
for (let n = 0; ; n++) {
|
||||
const value = k * n + c;
|
||||
if (value > this.max) break;
|
||||
if (value >= 1) set.add(value);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildEven(): Set<number> {
|
||||
return this.buildProgression(2, 0);
|
||||
}
|
||||
|
||||
private buildOdd(): Set<number> {
|
||||
return this.buildProgression(2, -1);
|
||||
}
|
||||
|
||||
private tryReadKeyword(): 'even' | 'odd' | null {
|
||||
const start = this.idx;
|
||||
const word = this.readWord();
|
||||
if (!word) return null;
|
||||
const lower = word.toLowerCase();
|
||||
if (lower === 'even' || lower === 'odd') {
|
||||
return lower as 'even' | 'odd';
|
||||
}
|
||||
// Not a keyword; rewind
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryReadProgression(): { k: number; c: number } | null {
|
||||
const start = this.idx;
|
||||
this.skipWs();
|
||||
const k = this.tryReadNumber();
|
||||
if (k === null) {
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
this.skipWs();
|
||||
// Optional '*'
|
||||
if (this.peek('*')) this.consume(1);
|
||||
this.skipWs();
|
||||
if (!this.peek('n') && !this.peek('N')) {
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
this.consume(1); // consume 'n'
|
||||
this.skipWs();
|
||||
// Optional (+|-) c
|
||||
let c = 0;
|
||||
if (this.peek('+') || this.peek('-')) {
|
||||
const sign = this.src[this.idx];
|
||||
this.consume(1);
|
||||
this.skipWs();
|
||||
const cVal = this.tryReadNumber();
|
||||
if (cVal === null) {
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
c = sign === '-' ? -cVal : cVal;
|
||||
}
|
||||
return { k, c };
|
||||
}
|
||||
|
||||
private tryReadNumber(): number | null {
|
||||
this.skipWs();
|
||||
const m = this.src.slice(this.idx).match(/^(\d+)/);
|
||||
if (!m) return null;
|
||||
this.consume(m[1].length);
|
||||
const num = parseInt(m[1], 10);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
private readRequiredNumber(): number {
|
||||
const n = this.tryReadNumber();
|
||||
if (n === null) throw new Error('Expected number');
|
||||
return n;
|
||||
}
|
||||
|
||||
private readWord(): string | null {
|
||||
this.skipWs();
|
||||
const m = this.src.slice(this.idx).match(/^([A-Za-z]+)/);
|
||||
if (!m) return null;
|
||||
this.consume(m[1].length);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
private tryConsumeNot(): boolean {
|
||||
const start = this.idx;
|
||||
const word = this.readWord();
|
||||
if (!word) {
|
||||
this.idx = start;
|
||||
return false;
|
||||
}
|
||||
if (word.toLowerCase() === 'not') {
|
||||
return true;
|
||||
}
|
||||
this.idx = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
private peekWordOrSymbol(): { type: 'word' | 'symbol'; value: string; raw: string; length: number } | null {
|
||||
this.skipWs();
|
||||
if (this.idx >= this.src.length) return null;
|
||||
const ch = this.src[this.idx];
|
||||
if (/[A-Za-z]/.test(ch)) {
|
||||
const start = this.idx;
|
||||
const word = this.readWord();
|
||||
if (!word) return null;
|
||||
const lower = word.toLowerCase();
|
||||
// Always rewind; the caller will consume if it uses this token
|
||||
const len = word.length;
|
||||
this.idx = start;
|
||||
if (lower === 'and' || lower === 'or') {
|
||||
return { type: 'word', value: lower, raw: word, length: len };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (ch === '&' || ch === '|' || ch === ',') {
|
||||
return { type: 'symbol', value: ch, raw: ch, length: 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private skipWs() {
|
||||
while (this.idx < this.src.length && /\s/.test(this.src[this.idx])) this.idx++;
|
||||
}
|
||||
|
||||
private peek(s: string): boolean {
|
||||
return this.src.startsWith(s, this.idx);
|
||||
}
|
||||
|
||||
private consume(n: number) {
|
||||
this.idx += n;
|
||||
}
|
||||
}
|
||||
|
||||
function union(a: Set<number>, b: Set<number>): Set<number> {
|
||||
if (a.size === 0) return new Set(b);
|
||||
if (b.size === 0) return new Set(a);
|
||||
const out = new Set<number>(a);
|
||||
for (const v of b) out.add(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function intersect(a: Set<number>, b: Set<number>): Set<number> {
|
||||
if (a.size === 0 || b.size === 0) return new Set<number>();
|
||||
const out = new Set<number>();
|
||||
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
||||
for (const v of small) if (large.has(v)) out.add(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function complement(a: Set<number>, max: number): Set<number> {
|
||||
const out = new Set<number>();
|
||||
for (let i = 1; i <= max; i++) if (!a.has(i)) out.add(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
23
frontend/src/utils/pageSelection.ts
Normal file
23
frontend/src/utils/pageSelection.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const validatePageNumbers = (pageNumbers: string): boolean => {
|
||||
if (!pageNumbers.trim()) return false;
|
||||
|
||||
// Normalize input for validation: remove spaces around commas and other spaces
|
||||
const normalized = pageNumbers.replace(/\s*,\s*/g, ',').replace(/\s+/g, '');
|
||||
const parts = normalized.split(',');
|
||||
|
||||
// Regular expressions for different page number formats
|
||||
const allToken = /^all$/i; // Select all pages
|
||||
const singlePageRegex = /^[1-9]\d*$/; // Single page: positive integers only (no 0)
|
||||
const rangeRegex = /^[1-9]\d*-(?:[1-9]\d*)?$/; // Range: 1-5 or open range 10-
|
||||
const mathRegex = /^(?=.*n)[0-9n+\-*/() ]+$/; // Mathematical expressions with n and allowed chars
|
||||
|
||||
return parts.every(part => {
|
||||
if (!part) return false;
|
||||
return (
|
||||
allToken.test(part) ||
|
||||
singlePageRegex.test(part) ||
|
||||
rangeRegex.test(part) ||
|
||||
mathRegex.test(part)
|
||||
);
|
||||
});
|
||||
};
|
@ -27,6 +27,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/remove-password': 'remove-password',
|
||||
'/single-large-page': 'single-large-page',
|
||||
'/repair': 'repair',
|
||||
'/rotate-pdf': 'rotate',
|
||||
'/unlock-pdf-forms': 'unlock-pdf-forms',
|
||||
'/remove-certificate-sign': 'remove-certificate-sign',
|
||||
'/remove-cert-sign': 'remove-certificate-sign'
|
||||
|
Loading…
x
Reference in New Issue
Block a user