Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into feature/V2/AddStamp

This commit is contained in:
EthanHealy01 2025-09-18 13:37:06 +01:00
commit 0578782b12
65 changed files with 5256 additions and 496 deletions

View File

@ -14,6 +14,7 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0", "@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1", "@mantine/core": "^8.0.1",
"@mantine/dates": "^8.0.1",
"@mantine/dropzone": "^8.0.1", "@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1", "@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0", "@mui/icons-material": "^7.1.0",
@ -1653,6 +1654,22 @@
"react-dom": "^18.x || ^19.x" "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": { "node_modules/@mantine/dropzone": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
@ -4367,6 +4384,13 @@
"node": ">=18" "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": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",

View File

@ -10,6 +10,7 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0", "@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1", "@mantine/core": "^8.0.1",
"@mantine/dates": "^8.0.1",
"@mantine/dropzone": "^8.0.1", "@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1", "@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0", "@mui/icons-material": "^7.1.0",

View File

@ -797,9 +797,27 @@
"rotate": { "rotate": {
"tags": "server side", "tags": "server side",
"title": "Rotate PDF", "title": "Rotate PDF",
"header": "Rotate PDF", "submit": "Apply Rotation",
"selectAngle": "Select rotation angle (in multiples of 90 degrees):", "error": {
"submit": "Rotate" "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": { "convert": {
"title": "Convert", "title": "Convert",
@ -1125,15 +1143,46 @@
"removePages": { "removePages": {
"tags": "Remove pages,delete pages", "tags": "Remove pages,delete pages",
"title": "Remove Pages", "title": "Remove Pages",
"pageNumbers": "Pages to Remove", "pageNumbers": {
"pageNumbersPlaceholder": "e.g. 1,3,5-7", "label": "Pages to Remove",
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", "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", "filenamePrefix": "pages_removed",
"files": { "files": {
"placeholder": "Select a PDF file in the main view to get started" "placeholder": "Select a PDF file in the main view to get started"
}, },
"settings": { "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": { "error": {
"failed": "An error occurred whilst removing pages." "failed": "An error occurred whilst removing pages."
@ -1145,9 +1194,7 @@
}, },
"pageSelection": { "pageSelection": {
"tooltip": { "tooltip": {
"header": { "header": { "title": "Page Selection Guide" },
"title": "Page Selection Guide"
},
"basic": { "basic": {
"title": "Basic Usage", "title": "Basic Usage",
"text": "Select specific pages from your PDF document using simple syntax.", "text": "Select specific pages from your PDF document using simple syntax.",
@ -1164,7 +1211,74 @@
"bullet1": "Page numbers start from 1 (not 0)", "bullet1": "Page numbers start from 1 (not 0)",
"bullet2": "Spaces are automatically removed", "bullet2": "Spaces are automatically removed",
"bullet3": "Invalid expressions are ignored" "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": { "compressPdfs": {
@ -1189,24 +1303,127 @@
}, },
"changeMetadata": { "changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats", "tags": "Title,author,date,creation,time,publisher,producer,stats",
"title": "Change Metadata",
"header": "Change Metadata", "header": "Change Metadata",
"selectText": { "submit": "Change",
"1": "Please edit the variables you wish to change", "filenamePrefix": "metadata",
"2": "Delete all metadata", "settings": {
"3": "Show Custom Metadata:", "title": "Metadata Settings"
"4": "Other Metadata:",
"5": "Add Custom Metadata Entry"
}, },
"author": "Author:", "standardFields": {
"creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):", "title": "Standard Fields"
"creator": "Creator:", },
"keywords": "Keywords:", "deleteAll": {
"modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):", "label": "Remove Existing Metadata",
"producer": "Producer:", "checkbox": "Delete all metadata"
"subject": "Subject:", },
"trapped": "Trapped:", "title": {
"submit": "Change" "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": { "fileToPDF": {
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint", "tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",
@ -1492,11 +1709,46 @@
"tags": "cleanup,streamline,non-content,organize", "tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks", "title": "Remove Blanks",
"header": "Remove Blank Pages", "header": "Remove Blank Pages",
"threshold": "Pixel Whiteness Threshold:", "settings": {
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.", "title": "Settings"
"whitePercent": "White Percent (%):", },
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed", "threshold": {
"submit": "Remove Blanks" "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": { "removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove", "tags": "comments,highlight,notes,markup,remove",

View File

@ -745,15 +745,46 @@
"removePages": { "removePages": {
"tags": "Remove pages,delete pages", "tags": "Remove pages,delete pages",
"title": "Remove Pages", "title": "Remove Pages",
"pageNumbers": "Pages to Remove", "pageNumbers": {
"pageNumbersPlaceholder": "e.g. 1,3,5-7", "label": "Pages to Remove",
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", "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", "filenamePrefix": "pages_removed",
"files": { "files": {
"placeholder": "Select a PDF file in the main view to get started" "placeholder": "Select a PDF file in the main view to get started"
}, },
"settings": { "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": { "error": {
"failed": "An error occurred while removing pages." "failed": "An error occurred while removing pages."
@ -807,7 +838,74 @@
"bullet1": "Page numbers start from 1 (not 0)", "bullet1": "Page numbers start from 1 (not 0)",
"bullet2": "Spaces are automatically removed", "bullet2": "Spaces are automatically removed",
"bullet3": "Invalid expressions are ignored" "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": { "compressPdfs": {
@ -1013,11 +1111,46 @@
"tags": "cleanup,streamline,non-content,organize", "tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks", "title": "Remove Blanks",
"header": "Remove Blank Pages", "header": "Remove Blank Pages",
"threshold": "Pixel Whiteness Threshold:", "settings": {
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.", "title": "Settings"
"whitePercent": "White Percent (%):", },
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed", "threshold": {
"submit": "Remove Blanks" "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": { "removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove", "tags": "comments,highlight,notes,markup,remove",

View File

@ -1,12 +1,16 @@
import React from 'react'; import { useState, useEffect } from 'react';
import { Group, TextInput, Button, Text } from '@mantine/core'; 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 { interface BulkSelectionPanelProps {
csvInput: string; csvInput: string;
setCsvInput: (value: string) => void; setCsvInput: (value: string) => void;
selectedPageIds: string[]; selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] }; displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: () => void; onUpdatePagesFromCSV: (override?: string) => void;
} }
const BulkSelectionPanel = ({ const BulkSelectionPanel = ({
@ -16,31 +20,56 @@ const BulkSelectionPanel = ({
displayDocument, displayDocument,
onUpdatePagesFromCSV, onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => { }: 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 ( return (
<> <div className={classes.panelContainer}>
<Group> <PageSelectionInput
<TextInput csvInput={csvInput}
value={csvInput} setCsvInput={setCsvInput}
onChange={(e) => setCsvInput(e.target.value)} onUpdatePagesFromCSV={onUpdatePagesFromCSV}
placeholder="1,3,5-10" onClear={handleClear}
label="Page Selection" advancedOpened={advancedOpened}
onBlur={onUpdatePagesFromCSV} onToggleAdvanced={setAdvancedOpened}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
style={{ flex: 1 }}
/> />
<Button onClick={onUpdatePagesFromCSV} mt="xl">
Apply <SelectedPagesDisplay
</Button> selectedPageIds={selectedPageIds}
</Group> displayDocument={displayDocument}
{selectedPageIds.length > 0 && ( syntaxError={syntaxError}
<Text size="sm" c="dimmed" mt="sm"> />
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id); <AdvancedSelectionPanel
return page?.pageNumber || 0; csvInput={csvInput}
}).filter(n => n > 0).join(', ') : ''}) setCsvInput={setCsvInput}
</Text> onUpdatePagesFromCSV={onUpdatePagesFromCSV}
)} maxPages={maxPages}
</> advancedOpened={advancedOpened}
/>
</div>
); );
}; };

View File

@ -171,7 +171,8 @@ const PageEditor = ({
}, },
() => splitPositions, () => splitPositions,
setSplitPositions, setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds) () => getPageNumbersFromIds(selectedPageIds),
closePdf
); );
undoManagerRef.current.executeCommand(deleteCommand); undoManagerRef.current.executeCommand(deleteCommand);
} }
@ -228,7 +229,8 @@ const PageEditor = ({
}, },
() => splitPositions, () => splitPositions,
setSplitPositions, setSplitPositions,
() => selectedPageNumbers () => selectedPageNumbers,
closePdf
); );
undoManagerRef.current.executeCommand(deleteCommand); undoManagerRef.current.executeCommand(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]); }, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
@ -246,7 +248,8 @@ const PageEditor = ({
}, },
() => splitPositions, () => splitPositions,
setSplitPositions, setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds) () => getPageNumbersFromIds(selectedPageIds),
closePdf
); );
undoManagerRef.current.executeCommand(deleteCommand); undoManagerRef.current.executeCommand(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]); }, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);

View File

@ -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;

View File

@ -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}`;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -59,6 +59,7 @@ export class DeletePagesCommand extends DOMCommand {
private originalSelectedPages: number[] = []; private originalSelectedPages: number[] = [];
private hasExecuted: boolean = false; private hasExecuted: boolean = false;
private pageIdsToDelete: string[] = []; private pageIdsToDelete: string[] = [];
private onAllPagesDeleted?: () => void;
constructor( constructor(
private pagesToDelete: number[], private pagesToDelete: number[],
@ -67,9 +68,11 @@ export class DeletePagesCommand extends DOMCommand {
private setSelectedPages: (pages: number[]) => void, private setSelectedPages: (pages: number[]) => void,
private getSplitPositions: () => Set<number>, private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void, private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[] private getSelectedPages: () => number[],
onAllPagesDeleted?: () => void
) { ) {
super(); super();
this.onAllPagesDeleted = onAllPagesDeleted;
} }
execute(): void { execute(): void {
@ -99,7 +102,13 @@ export class DeletePagesCommand extends DOMCommand {
!this.pageIdsToDelete.includes(page.id) !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 // Renumber remaining pages
remainingPages.forEach((page, index) => { remainingPages.forEach((page, index) => {

View File

@ -11,6 +11,7 @@ import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip'; import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel'; import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
export default function RightRail() { export default function RightRail() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -111,50 +112,13 @@ export default function RightRail() {
setSelectedFiles([]); setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]); }, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
// CSV parsing functions for page selection const updatePagesFromCSV = useCallback((override?: string) => {
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 maxPages = pageEditorFunctions?.totalPages || 0; 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); const normalized = parseSelection(override ?? csvInput, maxPages);
// Use PageEditor's function to set selected pages
pageEditorFunctions?.handleSetSelectedPages?.(normalized); pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, parseCSVInput, pageEditorFunctions]); }, [csvInput, pageEditorFunctions]);
// Sync csvInput with PageEditor's selected pages // Do not overwrite user's expression input when selection changes.
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]);
// Clear CSV input when files change (use stable signature to avoid ref churn) // Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => { useEffect(() => {
@ -260,7 +224,7 @@ export default function RightRail() {
</div> </div>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<div style={{ minWidth: 280 }}> <div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<BulkSelectionPanel <BulkSelectionPanel
csvInput={csvInput} csvInput={csvInput}
setCsvInput={setCsvInput} setCsvInput={setCsvInput}

View File

@ -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 { createPortal } from 'react-dom';
import LocalIcon from './LocalIcon'; import LocalIcon from './LocalIcon';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils'; import { addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition'; import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips'; import { TooltipTip } from '../../types/tips';
import { TooltipContent } from './tooltip/TooltipContent'; import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext'; import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css' import styles from './tooltip/Tooltip.module.css';
export interface TooltipProps { export interface TooltipProps {
sidebarTooltip?: boolean; sidebarTooltip?: boolean;
@ -21,12 +21,12 @@ export interface TooltipProps {
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
arrow?: boolean; arrow?: boolean;
portalTarget?: HTMLElement; portalTarget?: HTMLElement;
header?: { header?: { title: string; logo?: React.ReactNode };
title: string;
logo?: React.ReactNode;
};
delay?: number; delay?: number;
containerStyle?: React.CSSProperties; containerStyle?: React.CSSProperties;
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
} }
export const Tooltip: React.FC<TooltipProps> = ({ export const Tooltip: React.FC<TooltipProps> = ({
@ -44,57 +44,41 @@ export const Tooltip: React.FC<TooltipProps> = ({
portalTarget, portalTarget,
header, header,
delay = 0, delay = 0,
containerStyle={}, containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
}) => { }) => {
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = 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) { if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current); clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null; openTimeoutRef.current = null;
} }
}; }, []);
// Get sidebar context for tooltip positioning
const sidebarContext = sidebarTooltip ? useSidebarContext() : null; const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
// Always use controlled mode - if no controlled props provided, use internal state
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen; const open = isControlled ? !!controlledOpen : internalOpen;
const handleOpenChange = (newOpen: boolean) => { const setOpen = useCallback(
clearTimers(); (newOpen: boolean) => {
if (isControlled) { if (newOpen === open) return; // avoid churn
onOpenChange?.(newOpen); if (isControlled) onOpenChange?.(newOpen);
} else { else setInternalOpen(newOpen);
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({ const { coords, positionReady } = useTooltipPosition({
open, open,
sidebarTooltip, sidebarTooltip,
@ -103,56 +87,209 @@ export const Tooltip: React.FC<TooltipProps> = ({
triggerRef, triggerRef,
tooltipRef, tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs, sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState sidebarState: sidebarContext?.sidebarState,
}); });
// Add document click listener for unpinning // Close on outside click: pinned → close; not pinned → optionally close
useEffect(() => { 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 (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); return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
} }
}, [isPinned]); }, [open, isPinned, handleDocumentClick]);
useEffect(() => { useEffect(() => () => clearTimers(), [clearTimers]);
return () => {
clearTimers();
};
}, []);
const arrowClass = useMemo(() => {
const getArrowClass = () => {
// No arrow for sidebar tooltips
if (sidebarTooltip) return null; 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) { const getArrowStyleClass = useCallback(
case 'top': return "tooltip-arrow tooltip-arrow-bottom"; (key: string) =>
case 'bottom': return "tooltip-arrow tooltip-arrow-top"; styles[key as keyof typeof styles] ||
case 'left': return "tooltip-arrow tooltip-arrow-left"; styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
case 'right': return "tooltip-arrow tooltip-arrow-right"; '',
default: return "tooltip-arrow tooltip-arrow-right"; []
);
// === 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) => { // Ignore transient leave between mousedown and click
const styleKey = arrowClass.split(' ')[1]; if (clickPendingRef.current) {
// Handle both kebab-case and camelCase CSS module exports (children.props as any)?.onPointerLeave?.(e);
return styles[styleKey as keyof typeof styles] || return;
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] || }
'';
}; 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 shouldShowTooltip = open;
const tooltipElement = shouldShowTooltip ? ( const tooltipElement = shouldShowTooltip ? (
<div <div
id={tooltipIdRef.current}
ref={tooltipRef} ref={tooltipRef}
role="tooltip"
tabIndex={-1}
onPointerEnter={handleTooltipPointerEnter}
onPointerLeave={handleTooltipPointerLeave}
style={{ style={{
position: 'fixed', position: 'fixed',
top: coords.top, top: coords.top,
left: coords.left, left: coords.left,
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)), width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth: minWidth, minWidth,
zIndex: 9999, zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden', visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0, opacity: positionReady ? 1 : 0,
@ -160,7 +297,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
...containerStyle, ...containerStyle,
}} }}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={handleTooltipClick} onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
> >
{isPinned && ( {isPinned && (
<button <button
@ -168,94 +305,45 @@ export const Tooltip: React.FC<TooltipProps> = ({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsPinned(false); setIsPinned(false);
handleOpenChange(false); setOpen(false);
}} }}
title="Close tooltip" title="Close tooltip"
aria-label="Close tooltip"
> >
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" /> <LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button> </button>
)} )}
{arrow && getArrowClass() && ( {arrow && !sidebarTooltip && (
<div <div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`} className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
style={coords.arrowOffset !== null ? { style={
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset coords.arrowOffset !== null
} : undefined} ? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
: undefined
}
/> />
)} )}
{header && ( {header && (
<div className={styles['tooltip-header']}> <div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}> <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> </div>
<span className={styles['tooltip-title']}>{header.title}</span> <span className={styles['tooltip-title']}>{header.title}</span>
</div> </div>
)} )}
<TooltipContent <TooltipContent content={content} tips={tips} />
content={content}
tips={tips}
/>
</div> </div>
) : null; ) : 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 ( return (
<> <>
{childWithTooltipHandlers} {childWithHandlers}
{portalTarget && document.body.contains(portalTarget) {portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget) ? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement} : tooltipElement}

View File

@ -6,7 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote"; import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench'; import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { Tooltip } from "./Tooltip";
const viewOptionStyle = { const viewOptionStyle = {
display: 'inline-flex', 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) => [ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
{ {
label: ( label: (
@ -35,7 +35,6 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
}, },
{ {
label: ( label: (
<Tooltip content="Page Editor" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}> <div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? ( {currentView === "pageEditor" ? (
<> <>
@ -43,16 +42,17 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
<span>Page Editor</span> <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> </div>
</Tooltip>
), ),
value: "pageEditor", value: "pageEditor",
}, },
{ {
label: ( label: (
<Tooltip content="Active Files" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}> <div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? ( {currentView === "fileEditor" ? (
<> <>
@ -60,10 +60,12 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
<span>Active Files</span> <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> </div>
</Tooltip>
), ),
value: "fileEditor", value: "fileEditor",
}, },

View File

@ -1,177 +1,155 @@
# Tooltip Component # 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 clicktopin 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 ## Highlights
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality * 🎯 **Smart Positioning**: Keeps tooltips within the viewport and aligns the arrow dynamically.
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX * 📱 **Sidebar Aware**: Purposebuilt logic for sidebar/navigation contexts.
- 🌙 **Theme Support**: Built-in dark mode and theme variable support * ♿ **Accessible**: Keyboard and screenreader friendly (`role="tooltip"`, `aria-describedby`, Escape to close, focus/blur support).
- ⚡ **Performance**: Memoized calculations and efficient event handling * 🎨 **Customizable**: Arrows, headers, rich JSX content, and structured tips.
- 📜 **Scrollable**: Content area scrolls when content exceeds max height * 🌙 **Themeable**: Uses CSS variables; supports dark mode out of the box.
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin * ⚡ **Efficient**: Memoized calculations and stable callbacks to minimize rerenders.
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content * 📜 **Scrollable Content**: When content exceeds max height.
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior * 📌 **ClicktoPin**: (Optional) Pin open; close via outside click or close button.
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay` * 🔗 **LinkSafe**: Fully clickable links in descriptions, bullets, and custom content.
* 🖱️ **PointerFriendly**: Uses pointer events (works with mouse/pen/touch hover where applicable).
---
## Behavior ## Behavior
### Default Behavior (Controlled) ### Default
- **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
### Manual Control (Optional) * **Hover/Focus**: Opens on pointer **enter** or when the trigger receives **focus** (respects optional `delay`).
- Use `open` and `onOpenChange` props for complete external control * **Leave/Blur**: Closes on pointer **leave** (from trigger *and* tooltip) or when the trigger/tooltip **blurs** to the page—unless pinned.
- Useful for complex state management or custom interaction patterns * **Inside Tooltip**: Moving from trigger → tooltip keeps it open; moving out of both closes it (unless pinned).
* **Escape**: Press **Esc** to close.
### ClicktoPin (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**: Outsideclick closing when **not** pinned is configurable via `closeOnOutside` (default `true`).
---
## Installation
```tsx
import { Tooltip } from '@/components/shared';
```
---
## Basic Usage ## Basic Usage
```tsx ```tsx
import { Tooltip } from '@/components/shared'; <Tooltip content="This is a helpful tooltip">
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)
```tsx
// Simple tooltip with hover and click-to-pin
<Tooltip content="This tooltip appears on hover and pins on click">
<button>Hover me</button> <button>Hover me</button>
</Tooltip> </Tooltip>
```
// Structured content with tips With structured tips and a header:
```tsx
<Tooltip <Tooltip
tips={[ tips={[{
{ title: 'OCR Mode',
title: "OCR Mode", description: 'Choose how to process text in your documents.',
description: "Choose how to process text in your documents.",
bullets: [ bullets: [
"<strong>Auto</strong> skips pages that already contain text.", '<strong>Auto</strong> skips pages that already contain text.',
"<strong>Force</strong> re-processes every page.", '<strong>Force</strong> re-processes every page.',
"<strong>Strict</strong> stops if text is found.", '<strong>Strict</strong> stops if text is found.',
"<a href='https://docs.example.com' target='_blank'>Learn more</a>" "<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" /> }}
header={{
title: "Basic Settings Overview",
logo: <img src="/logo.svg" alt="Logo" />
}}
> >
<button>Settings</button> <button>Settings</button>
</Tooltip> </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 triggers 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 ### Optional Hover Delay
```tsx ```tsx
// Show after a 1s hover <Tooltip content="Appears after 1s" delay={1000}>
<Tooltip content="Appears after a long hover" delay={1000} /> <button>Delayed</button>
// 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> </Tooltip>
``` ```
@ -180,63 +158,55 @@ interface TooltipTip {
```tsx ```tsx
function ManualControlTooltip() { function ManualControlTooltip() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Tooltip <Tooltip content="Fully controlled tooltip" open={open} onOpenChange={setOpen}>
content="Fully controlled tooltip" <button onClick={() => setOpen(!open)}>Toggle tooltip</button>
open={open}
onOpenChange={setOpen}
>
<button onClick={() => setOpen(!open)}>
Toggle tooltip
</button>
</Tooltip> </Tooltip>
); );
} }
``` ```
## Click-to-Pin Interaction ### Sidebar Tooltip
### How to Use (Default Behavior) ```tsx
1. **Hover** over the trigger element to show the tooltip <Tooltip content="Appears to the right of the sidebar" sidebarTooltip>
2. **Click** the trigger element to pin the tooltip open <div className="sidebar-item">📁 File Manager</div>
3. **Click** the red X button in the top-right corner to close </Tooltip>
4. **Click** anywhere outside the tooltip to close ```
5. **Click** the trigger again to toggle pin state
### Visual States ### Mixed Content
- **Unpinned**: Normal tooltip appearance
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
## 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 ## Positioning Notes
- **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
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 sidebars edge and clamps vertically. Arrows are disabled in sidebar mode.
## Positioning Logic ---
### Regular Tooltips ## Caveats & Tips
- 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
## Timing Details * Ensure your container doesnt block pointer events between trigger and tooltip.
* When using `portalTarget`, confirm its 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 ## Changelog (since previous README)
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
- Vertical positioning follows the trigger but clamps to viewport * Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`).
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position * Clarified outsideclick behavior for pinned vs unpinned.
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed * Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`) * Removed references to nonexistent props (e.g., `delayAppearance`).
- **No arrows** - sidebar tooltips don't show arrows * Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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;

View File

@ -48,6 +48,7 @@ const renderTooltipTitle = (
tips={tooltip.tips} tips={tooltip.tips}
header={tooltip.header} header={tooltip.header}
sidebarTooltip={true} sidebarTooltip={true}
pinOnClick={true}
> >
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}> <Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={400} size="sm"> <Text fw={400} size="sm">

View 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")
]
}
]
};
};

View 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`,
]
}
]
};
};

View 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")
]
}
]
};
};

View 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")
]
}
]
};
};

View 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."),
},
],
};
};

View File

@ -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) * 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(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getFiles(), files: selectors.getFiles(),
records: selectors.getStirlingFileStubs(), fileStubs: selectors.getStirlingFileStubs(),
fileIds: state.files.ids fileIds: state.files.ids
}), [state.files.ids, selectors]); }), [state.files.ids, selectors]);
} }
@ -136,12 +136,12 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
/** /**
* Hook for selected files (optimized for selection-based UI) * 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(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
selectedFiles: selectors.getSelectedFiles(), selectedFiles: selectors.getSelectedFiles(),
selectedRecords: selectors.getSelectedStirlingFileStubs(), selectedFileStubs: selectors.getSelectedStirlingFileStubs(),
selectedFileIds: state.ui.selectedFileIds selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]); }), [state.ui.selectedFileIds, selectors]);
} }

View File

@ -8,6 +8,8 @@ import ConvertPanel from "../tools/Convert";
import Sanitize from "../tools/Sanitize"; import Sanitize from "../tools/Sanitize";
import AddPassword from "../tools/AddPassword"; import AddPassword from "../tools/AddPassword";
import ChangePermissions from "../tools/ChangePermissions"; import ChangePermissions from "../tools/ChangePermissions";
import RemoveBlanks from "../tools/RemoveBlanks";
import RemovePages from "../tools/RemovePages";
import RemovePassword from "../tools/RemovePassword"; import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AddWatermark from "../tools/AddWatermark"; import AddWatermark from "../tools/AddWatermark";
@ -19,6 +21,8 @@ import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms"; import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign"; import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import Flatten from "../tools/Flatten"; import Flatten from "../tools/Flatten";
import Rotate from "../tools/Rotate";
import ChangeMetadata from "../tools/ChangeMetadata";
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation"; 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 { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation"; import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation"; 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 CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; 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 ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings"; import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings"; import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import RotateSettings from "../components/tools/rotate/RotateSettings";
import Redact from "../tools/Redact"; import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale"; import AdjustPageScale from "../tools/AdjustPageScale";
import { ToolId } from "../types/toolId"; import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings'; import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings"; 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 const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -297,10 +305,14 @@ export function useFlatToolRegistry(): ToolRegistry {
"change-metadata": { "change-metadata": {
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.changeMetadata.title", "Change Metadata"), name: t("home.changeMetadata.title", "Change Metadata"),
component: null, component: ChangeMetadata,
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"), description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW, subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: -1,
endpoints: ["update-metadata"],
operationConfig: changeMetadataOperationConfig,
settingsComponent: ChangeMetadataSingleStep,
}, },
// Page Formatting // Page Formatting
@ -315,10 +327,14 @@ export function useFlatToolRegistry(): ToolRegistry {
rotate: { rotate: {
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.rotate.title", "Rotate"), name: t("home.rotate.title", "Rotate"),
component: null, component: Rotate,
description: t("home.rotate.desc", "Easily rotate your PDFs."), description: t("home.rotate.desc", "Easily rotate your PDFs."),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING, subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["rotate-pdf"],
operationConfig: rotateOperationConfig,
settingsComponent: RotateSettings,
}, },
split: { split: {
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
@ -419,18 +435,22 @@ export function useFlatToolRegistry(): ToolRegistry {
removePages: { removePages: {
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removePages.title", "Remove Pages"), name: t("home.removePages.title", "Remove Pages"),
component: null, component: RemovePages,
description: t("home.removePages.desc", "Remove specific pages from a PDF document"), description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL, subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-pages"],
}, },
"remove-blank-pages": { "remove-blank-pages": {
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removeBlanks.title", "Remove Blank Pages"), name: t("home.removeBlanks.title", "Remove Blank Pages"),
component: null, component: RemoveBlanks,
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"), description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL, subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-blanks"],
}, },
"remove-annotations": { "remove-annotations": {
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -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');
});
});

View File

@ -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.'))
});
};

View File

@ -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');
});
});

View File

@ -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
};
};

View File

@ -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,
};
};

View File

@ -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')
)
});
};

View File

@ -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,
});
};

View File

@ -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')
)
});
};

View File

@ -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),
});
};

View 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);
});
});

View 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.'))
});
};

View 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);
});
});

View 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,
};
};

View 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
};
}

View File

@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { useFileSelection } from '../../../contexts/FileContext'; import { useFileSelection } from '../../../contexts/FileContext';
import { useEndpointEnabled } from '../../useEndpointConfig'; import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool'; import { BaseToolProps } from '../../../types/tool';
@ -45,6 +45,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
// File selection // File selection
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
const previousFileCount = useRef(selectedFiles.length);
// Tool-specific hooks // Tool-specific hooks
const params = useParams(); const params = useParams();
@ -67,6 +68,18 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
} }
}, [selectedFiles.length]); }, [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 // Standard handlers
const handleExecute = useCallback(async () => { const handleExecute = useCallback(async () => {
try { try {

View File

@ -1,4 +1,5 @@
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '../vite-env.d.ts'; import '../vite-env.d.ts';
import './index.css'; // Import Tailwind CSS import './index.css'; // Import Tailwind CSS
import React from 'react'; import React from 'react';

View 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
};
}

View File

@ -6,11 +6,12 @@
*/ */
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
const { getDocument, GlobalWorkerOptions } = pdfjsLib; const { getDocument, GlobalWorkerOptions } = pdfjsLib;
class PDFWorkerManager { class PDFWorkerManager {
private static instance: PDFWorkerManager; private static instance: PDFWorkerManager;
private activeDocuments = new Set<any>(); private activeDocuments = new Set<PDFDocumentProxy>();
private workerCount = 0; private workerCount = 0;
private maxWorkers = 10; // Limit concurrent workers private maxWorkers = 10; // Limit concurrent workers
private isInitialized = false; private isInitialized = false;
@ -48,7 +49,7 @@ class PDFWorkerManager {
stopAtErrors?: boolean; stopAtErrors?: boolean;
verbosity?: number; verbosity?: number;
} = {} } = {}
): Promise<any> { ): Promise<PDFDocumentProxy> {
// Wait if we've hit the worker limit // Wait if we've hit the worker limit
if (this.activeDocuments.size >= this.maxWorkers) { if (this.activeDocuments.size >= this.maxWorkers) {
await this.waitForAvailableWorker(); await this.waitForAvailableWorker();
@ -104,7 +105,7 @@ class PDFWorkerManager {
/** /**
* Properly destroy a PDF document and clean up resources * Properly destroy a PDF document and clean up resources
*/ */
destroyDocument(pdf: any): void { destroyDocument(pdf: PDFDocumentProxy): void {
if (this.activeDocuments.has(pdf)) { if (this.activeDocuments.has(pdf)) {
try { try {
pdf.destroy(); pdf.destroy();

View File

@ -181,6 +181,11 @@
--information-text-bg: #eaeaea; --information-text-bg: #eaeaea;
--information-text-color: #5e5e5e; --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"] { [data-mantine-color-scheme="dark"] {
@ -327,6 +332,13 @@
--information-text-bg: #292e34; --information-text-bg: #292e34;
--information-text-color: #ececec; --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 */ /* Dropzone drop state styling */

View 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;

View File

@ -16,7 +16,7 @@ const Merge = (props: BaseToolProps) => {
// File selection hooks for custom sorting // File selection hooks for custom sorting
const { fileIds } = useAllFiles(); const { fileIds } = useAllFiles();
const { selectedRecords } = useSelectedFiles(); const { selectedFileStubs } = useSelectedFiles();
const { reorderFiles } = useFileManagement(); const { reorderFiles } = useFileManagement();
const base = useBaseTool( const base = useBaseTool(
@ -29,23 +29,23 @@ const Merge = (props: BaseToolProps) => {
// Custom file sorting logic for merge tool // Custom file sorting logic for merge tool
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => { const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => { const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => {
let comparison = 0; let comparison = 0;
switch (sortType) { switch (sortType) {
case 'filename': case 'filename':
comparison = recordA.name.localeCompare(recordB.name); comparison = stubA.name.localeCompare(stubB.name);
break; break;
case 'dateModified': case 'dateModified':
comparison = recordA.lastModified - recordB.lastModified; comparison = stubA.lastModified - stubB.lastModified;
break; break;
} }
return ascending ? comparison : -comparison; 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)); const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
reorderFiles([...selectedIds, ...deselectedIds]); reorderFiles([...selectedIds, ...deselectedIds]);
}, [selectedRecords, fileIds, reorderFiles]); }, [selectedFileStubs, fileIds, reorderFiles]);
return createToolFlow({ return createToolFlow({
files: { files: {

View 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;

View 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;

View 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;

View 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[];
}

View 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

View 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]);
});
});

View 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;
}

View 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)
);
});
};

View File

@ -27,6 +27,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/remove-password': 'remove-password', '/remove-password': 'remove-password',
'/single-large-page': 'single-large-page', '/single-large-page': 'single-large-page',
'/repair': 'repair', '/repair': 'repair',
'/rotate-pdf': 'rotate',
'/unlock-pdf-forms': 'unlock-pdf-forms', '/unlock-pdf-forms': 'unlock-pdf-forms',
'/remove-certificate-sign': 'remove-certificate-sign', '/remove-certificate-sign': 'remove-certificate-sign',
'/remove-cert-sign': 'remove-certificate-sign' '/remove-cert-sign': 'remove-certificate-sign'