Compare commits

..

No commits in common. "0578782b121147dc14c502274eef90f74b8354c3" and "bcba83ab43d498e8fa450cddd11c97c1a9a4c73d" have entirely different histories.

67 changed files with 493 additions and 5268 deletions

View File

@ -14,7 +14,6 @@
"@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1",
"@mantine/dates": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0",
@ -1654,22 +1653,6 @@
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dates": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz",
"integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"@mantine/core": "8.0.1",
"@mantine/hooks": "8.0.1",
"dayjs": ">=1.0.0",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dropzone": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
@ -4384,13 +4367,6 @@
"node": ">=18"
}
},
"node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",

View File

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

View File

@ -797,27 +797,9 @@
"rotate": {
"tags": "server side",
"title": "Rotate PDF",
"submit": "Apply Rotation",
"error": {
"failed": "An error occurred while rotating the PDF."
},
"preview": {
"title": "Rotation Preview"
},
"rotateLeft": "Rotate Anticlockwise",
"rotateRight": "Rotate Clockwise",
"tooltip": {
"header": {
"title": "Rotate Settings Overview"
},
"description": {
"text": "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation."
},
"controls": {
"title": "Controls",
"text": "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees."
}
}
"header": "Rotate PDF",
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
"submit": "Rotate"
},
"convert": {
"title": "Convert",
@ -1143,46 +1125,15 @@
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
"pageNumbers": {
"label": "Pages to Remove",
"placeholder": "e.g., 1,3,5-8,10",
"error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)"
},
"pageNumbers": "Pages to Remove",
"pageNumbersPlaceholder": "e.g. 1,3,5-7",
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7",
"filenamePrefix": "pages_removed",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"settings": {
"title": "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"
}
"title": "Page Selection"
},
"error": {
"failed": "An error occurred whilst removing pages."
@ -1194,7 +1145,9 @@
},
"pageSelection": {
"tooltip": {
"header": { "title": "Page Selection Guide" },
"header": {
"title": "Page Selection Guide"
},
"basic": {
"title": "Basic Usage",
"text": "Select specific pages from your PDF document using simple syntax.",
@ -1211,74 +1164,7 @@
"bullet1": "Page numbers start from 1 (not 0)",
"bullet2": "Spaces are automatically removed",
"bullet3": "Invalid expressions are ignored"
},
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": { "title": "Examples" }
}
},
"bulkSelection": {
"header": { "title": "Page Selection Guide" },
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": {
"title": "Examples",
"first50": "First 50",
"last50": "Last 50",
"every3rd": "Every 3rd",
"oddWithinExcluding": "Odd within 1-20 excluding 5-7",
"combineSets": "Combine sets"
},
"firstNPages": {
"title": "First N Pages",
"placeholder": "Number of pages"
},
"lastNPages": {
"title": "Last N Pages",
"placeholder": "Number of pages"
},
"everyNthPage": {
"title": "Every Nth Page",
"placeholder": "Step size"
},
"range": {
"title": "Range",
"fromPlaceholder": "From",
"toPlaceholder": "To"
},
"keywords": {
"title": "Keywords"
},
"advanced": {
"title": "Advanced"
}
},
"compressPdfs": {
@ -1303,127 +1189,24 @@
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
"title": "Change Metadata",
"header": "Change Metadata",
"submit": "Change",
"filenamePrefix": "metadata",
"settings": {
"title": "Metadata Settings"
"selectText": {
"1": "Please edit the variables you wish to change",
"2": "Delete all metadata",
"3": "Show Custom Metadata:",
"4": "Other Metadata:",
"5": "Add Custom Metadata Entry"
},
"standardFields": {
"title": "Standard Fields"
},
"deleteAll": {
"label": "Remove Existing Metadata",
"checkbox": "Delete all metadata"
},
"title": {
"label": "Title",
"placeholder": "Document title"
},
"author": {
"label": "Author",
"placeholder": "Document author"
},
"subject": {
"label": "Subject",
"placeholder": "Document subject"
},
"keywords": {
"label": "Keywords",
"placeholder": "Document keywords"
},
"creator": {
"label": "Creator",
"placeholder": "Document creator"
},
"producer": {
"label": "Producer",
"placeholder": "Document producer"
},
"dates": {
"title": "Date Fields"
},
"creationDate": {
"label": "Creation Date",
"placeholder": "Creation date"
},
"modificationDate": {
"label": "Modification Date",
"placeholder": "Modification date"
},
"trapped": {
"label": "Trapped Status",
"unknown": "Unknown",
"true": "True",
"false": "False"
},
"advanced": {
"title": "Advanced Options"
},
"customFields": {
"title": "Custom Metadata",
"description": "Add custom metadata fields to the document",
"add": "Add Field",
"key": "Key",
"keyPlaceholder": "Custom key",
"value": "Value",
"valuePlaceholder": "Custom value",
"remove": "Remove"
},
"results": {
"title": "Updated PDFs"
},
"error": {
"failed": "An error occurred while changing the PDF metadata."
},
"tooltip": {
"header": {
"title": "PDF Metadata Overview"
},
"standardFields": {
"title": "Standard Fields",
"text": "Common PDF metadata fields that describe the document.",
"bullet1": "Title: Document name or heading",
"bullet2": "Author: Person who created the document",
"bullet3": "Subject: Brief description of content",
"bullet4": "Keywords: Search terms for the document",
"bullet5": "Creator/Producer: Software used to create the PDF"
},
"dates": {
"title": "Date Fields",
"text": "When the document was created and modified.",
"bullet1": "Creation Date: When original document was made",
"bullet2": "Modification Date: When last changed"
},
"options": {
"title": "Additional Options",
"text": "Custom fields and privacy controls.",
"bullet1": "Custom Metadata: Add your own key-value pairs",
"bullet2": "Trapped Status: High-quality printing setting",
"bullet3": "Delete All: Remove all metadata for privacy"
},
"deleteAll": {
"title": "Remove Existing Metadata",
"text": "Complete metadata deletion to ensure privacy."
},
"customFields": {
"title": "Custom Metadata",
"text": "Add your own custom key-value metadata pairs.",
"bullet1": "Add any custom fields relevant to your document",
"bullet2": "Examples: Department, Project, Version, Status",
"bullet3": "Both key and value are required for each entry"
},
"advanced": {
"title": "Advanced Options",
"trapped": {
"title": "Trapped Status",
"description": "Indicates if document is prepared for high-quality printing.",
"bullet1": "True: Document has been trapped for printing",
"bullet2": "False: Document has not been trapped",
"bullet3": "Unknown: Trapped status is not specified"
}
}
}
"author": "Author:",
"creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):",
"creator": "Creator:",
"keywords": "Keywords:",
"modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):",
"producer": "Producer:",
"subject": "Subject:",
"trapped": "Trapped:",
"submit": "Change"
},
"fileToPDF": {
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",
@ -1709,46 +1492,11 @@
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"settings": {
"title": "Settings"
},
"threshold": {
"label": "Pixel Whiteness Threshold"
},
"whitePercent": {
"label": "White Percentage Threshold",
"unit": "%"
},
"includeBlankPages": {
"label": "Include detected blank pages"
},
"tooltip": {
"header": {
"title": "Remove Blank Pages Settings"
},
"threshold": {
"title": "Pixel Whiteness Threshold",
"text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.",
"bullet1": "0 = Pure black (most restrictive)",
"bullet2": "128 = Medium grey",
"bullet3": "255 = Pure white (least restrictive)"
},
"whitePercent": {
"title": "White Percentage Threshold",
"text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.",
"bullet1": "Lower values (e.g., 80%) = More pages removed",
"bullet2": "Higher values (e.g., 95%) = Only very blank pages removed",
"bullet3": "Use higher values for documents with light backgrounds"
},
"includeBlankPages": {
"title": "Include Detected Blank Pages",
"text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.",
"bullet1": "Useful for reviewing what was removed",
"bullet2": "Helps verify the detection accuracy",
"bullet3": "Can be disabled to reduce output file size"
}
},
"submit": "Remove blank pages"
"threshold": "Pixel Whiteness Threshold:",
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.",
"whitePercent": "White Percent (%):",
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed",
"submit": "Remove Blanks"
},
"removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove",

View File

@ -745,46 +745,15 @@
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
"pageNumbers": {
"label": "Pages to Remove",
"placeholder": "e.g., 1,3,5-8,10",
"error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)"
},
"pageNumbers": "Pages to Remove",
"pageNumbersPlaceholder": "e.g. 1,3,5-7",
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7",
"filenamePrefix": "pages_removed",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"settings": {
"title": "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"
}
"title": "Page Selection"
},
"error": {
"failed": "An error occurred while removing pages."
@ -838,74 +807,7 @@
"bullet1": "Page numbers start from 1 (not 0)",
"bullet2": "Spaces are automatically removed",
"bullet3": "Invalid expressions are ignored"
},
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": { "title": "Examples" }
}
},
"bulkSelection": {
"header": { "title": "Page Selection Guide" },
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": {
"title": "Examples",
"first50": "First 50",
"last50": "Last 50",
"every3rd": "Every 3rd",
"oddWithinExcluding": "Odd within 1-20 excluding 5-7",
"combineSets": "Combine sets"
},
"firstNPages": {
"title": "First N Pages",
"placeholder": "Number of pages"
},
"lastNPages": {
"title": "Last N Pages",
"placeholder": "Number of pages"
},
"everyNthPage": {
"title": "Every Nth Page",
"placeholder": "Step size"
},
"range": {
"title": "Range",
"fromPlaceholder": "From",
"toPlaceholder": "To"
},
"keywords": {
"title": "Keywords"
},
"advanced": {
"title": "Advanced"
}
},
"compressPdfs": {
@ -1111,46 +1013,11 @@
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"settings": {
"title": "Settings"
},
"threshold": {
"label": "Pixel Whiteness Threshold"
},
"whitePercent": {
"label": "White Percentage Threshold",
"unit": "%"
},
"includeBlankPages": {
"label": "Include detected blank pages"
},
"tooltip": {
"header": {
"title": "Remove Blank Pages Settings"
},
"threshold": {
"title": "Pixel Whiteness Threshold",
"text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.",
"bullet1": "0 = Pure black (most restrictive)",
"bullet2": "128 = Medium gray",
"bullet3": "255 = Pure white (least restrictive)"
},
"whitePercent": {
"title": "White Percentage Threshold",
"text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.",
"bullet1": "Lower values (e.g., 80%) = More pages removed",
"bullet2": "Higher values (e.g., 95%) = Only very blank pages removed",
"bullet3": "Use higher values for documents with light backgrounds"
},
"includeBlankPages": {
"title": "Include Detected Blank Pages",
"text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.",
"bullet1": "Useful for reviewing what was removed",
"bullet2": "Helps verify the detection accuracy",
"bullet3": "Can be disabled to reduce output file size"
}
},
"submit": "Remove blank pages"
"threshold": "Pixel Whiteness Threshold:",
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.",
"whitePercent": "White Percent (%):",
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed",
"submit": "Remove Blanks"
},
"removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove",

View File

@ -1,16 +1,12 @@
import { useState, useEffect } from 'react';
import classes from './bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '../../utils/bulkselection/parseSelection';
import PageSelectionInput from './bulkSelectionPanel/PageSelectionInput';
import SelectedPagesDisplay from './bulkSelectionPanel/SelectedPagesDisplay';
import AdvancedSelectionPanel from './bulkSelectionPanel/AdvancedSelectionPanel';
import React from 'react';
import { Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: (override?: string) => void;
onUpdatePagesFromCSV: () => void;
}
const BulkSelectionPanel = ({
@ -20,56 +16,31 @@ const BulkSelectionPanel = ({
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const maxPages = displayDocument?.pages?.length ?? 0;
// Validate input syntax and show lightweight feedback
useEffect(() => {
const text = (csvInput || '').trim();
if (!text) {
setSyntaxError(null);
return;
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
} catch {
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
}
}, [csvInput, maxPages]);
const handleClear = () => {
setCsvInput('');
onUpdatePagesFromCSV('');
};
return (
<div className={classes.panelContainer}>
<PageSelectionInput
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
onClear={handleClear}
advancedOpened={advancedOpened}
onToggleAdvanced={setAdvancedOpened}
/>
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={syntaxError}
/>
<AdvancedSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
maxPages={maxPages}
advancedOpened={advancedOpened}
/>
</div>
<>
<Group>
<TextInput
value={csvInput}
onChange={(e) => setCsvInput(e.target.value)}
placeholder="1,3,5-10"
label="Page Selection"
onBlur={onUpdatePagesFromCSV}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
style={{ flex: 1 }}
/>
<Button onClick={onUpdatePagesFromCSV} mt="xl">
Apply
</Button>
</Group>
{selectedPageIds.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
)}
</>
);
};

View File

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

View File

@ -1,147 +0,0 @@
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

@ -1,136 +0,0 @@
// 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

@ -1,295 +0,0 @@
.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

@ -1,74 +0,0 @@
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

@ -1,94 +0,0 @@
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

@ -1,104 +0,0 @@
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

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

View File

@ -11,7 +11,6 @@ import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
export default function RightRail() {
const { t } = useTranslation();
@ -112,13 +111,50 @@ export default function RightRail() {
setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
const updatePagesFromCSV = useCallback((override?: string) => {
const maxPages = pageEditorFunctions?.totalPages || 0;
const normalized = parseSelection(override ?? csvInput, maxPages);
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, pageEditorFunctions]);
// CSV parsing functions for page selection
const parseCSVInput = useCallback((csv: string) => {
const pageNumbers: number[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
// Do not overwrite user's expression input when selection changes.
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 normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
// Use PageEditor's function to set selected pages
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, parseCSVInput, pageEditorFunctions]);
// Sync csvInput with PageEditor's selected pages
useEffect(() => {
const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument
? pageEditorFunctions.selectedPageIds.map(id => {
const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0).sort((a, b) => a - b)
: [];
const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput);
}, [pageEditorFunctions?.selectedPageIds]);
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
@ -224,7 +260,7 @@ export default function RightRail() {
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<div style={{ minWidth: 280 }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}

View File

@ -1,12 +1,12 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import LocalIcon from './LocalIcon';
import { addEventListenerWithCleanup } from '../../utils/genericUtils';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips';
import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css';
import styles from './tooltip/Tooltip.module.css'
export interface TooltipProps {
sidebarTooltip?: boolean;
@ -21,12 +21,12 @@ export interface TooltipProps {
onOpenChange?: (open: boolean) => void;
arrow?: boolean;
portalTarget?: HTMLElement;
header?: { title: string; logo?: React.ReactNode };
header?: {
title: string;
logo?: React.ReactNode;
};
delay?: number;
containerStyle?: React.CSSProperties;
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@ -44,41 +44,57 @@ export const Tooltip: React.FC<TooltipProps> = ({
portalTarget,
header,
delay = 0,
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
containerStyle={},
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(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(() => {
const clearTimers = () => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
}, []);
};
// Get sidebar context for tooltip positioning
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
// Always use controlled mode - if no controlled props provided, use internal state
const isControlled = controlledOpen !== undefined;
const open = isControlled ? !!controlledOpen : internalOpen;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(newOpen: boolean) => {
if (newOpen === open) return; // avoid churn
if (isControlled) onOpenChange?.(newOpen);
else setInternalOpen(newOpen);
if (!newOpen) setIsPinned(false);
},
[isControlled, onOpenChange, open]
);
const handleOpenChange = (newOpen: boolean) => {
clearTimers();
if (isControlled) {
onOpenChange?.(newOpen);
} else {
setInternalOpen(newOpen);
}
// Reset pin state when closing
if (!newOpen) {
setIsPinned(false);
}
};
const handleTooltipClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsPinned(true);
};
const handleDocumentClick = (e: MouseEvent) => {
// If tooltip is pinned and we click outside of it, unpin it
if (isPinned && isClickOutside(e, tooltipRef.current)) {
setIsPinned(false);
handleOpenChange(false);
}
};
// Use the positioning hook
const { coords, positionReady } = useTooltipPosition({
open,
sidebarTooltip,
@ -87,209 +103,56 @@ export const Tooltip: React.FC<TooltipProps> = ({
triggerRef,
tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState,
sidebarState: sidebarContext?.sidebarState
});
// Close on outside click: pinned → close; not pinned → optionally close
const handleDocumentClick = useCallback(
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
if (!insideTooltip && !insideTrigger) {
setIsPinned(false);
setOpen(false);
}
return;
}
// Not pinned and configured to close on outside
if (closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen]
);
// Add document click listener for unpinning
useEffect(() => {
// Attach global click when open (so hover tooltips can also close on outside if desired)
if (open || isPinned) {
if (isPinned) {
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
}
}, [open, isPinned, handleDocumentClick]);
}, [isPinned]);
useEffect(() => () => clearTimers(), [clearTimers]);
const arrowClass = useMemo(() => {
if (sidebarTooltip) return null;
const map: Record<NonNullable<TooltipProps['position']>, string> = {
top: 'tooltip-arrow-bottom',
bottom: 'tooltip-arrow-top',
left: 'tooltip-arrow-left',
right: 'tooltip-arrow-right',
useEffect(() => {
return () => {
clearTimers();
};
return map[position] || map.right;
}, [position, sidebarTooltip]);
}, []);
const getArrowStyleClass = useCallback(
(key: string) =>
styles[key as keyof typeof styles] ||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
'',
[]
);
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay]);
const getArrowClass = () => {
// No arrow for sidebar tooltips
if (sidebarTooltip) return null;
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props]
);
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
case 'left': return "tooltip-arrow tooltip-arrow-left";
case 'right': return "tooltip-arrow tooltip-arrow-right";
default: return "tooltip-arrow tooltip-arrow-right";
}
};
const 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;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
clearTimers();
if (!isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
},
[children.props]
);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
},
[children.props]
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
clearTimers();
if (pinOnClick) {
e.preventDefault?.();
e.stopPropagation?.();
if (!open) setOpen(true);
setIsPinned(true);
clickPendingRef.current = false;
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props]
);
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props]
);
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
}, [setOpen]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
clearTimers();
}, [clearTimers]);
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]
);
// Enhance child with handlers and ref
const childWithHandlers = React.cloneElement(children as any, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') originalRef(node);
else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node;
},
'aria-describedby': open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
});
const getArrowStyleClass = (arrowClass: string) => {
const styleKey = arrowClass.split(' ')[1];
// Handle both kebab-case and camelCase CSS module exports
return styles[styleKey as keyof typeof styles] ||
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] ||
'';
};
// Always mount when open so we can measure; hide until positioned to avoid flash
const shouldShowTooltip = open;
const tooltipElement = shouldShowTooltip ? (
<div
id={tooltipIdRef.current}
ref={tooltipRef}
role="tooltip"
tabIndex={-1}
onPointerEnter={handleTooltipPointerEnter}
onPointerLeave={handleTooltipPointerLeave}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
minWidth: minWidth,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
@ -297,7 +160,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
...containerStyle,
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
onClick={handleTooltipClick}
>
{isPinned && (
<button
@ -305,48 +168,97 @@ export const Tooltip: React.FC<TooltipProps> = ({
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
setOpen(false);
handleOpenChange(false);
}}
title="Close tooltip"
aria-label="Close tooltip"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
{arrow && !sidebarTooltip && (
{arrow && getArrowClass() && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
style={
coords.arrowOffset !== null
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
: undefined
}
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
style={coords.arrowOffset !== null ? {
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
} : undefined}
/>
)}
{header && (
<div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}>
{header.logo || (
<img
src="/logo-tooltip.svg"
alt="Stirling PDF"
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
/>
)}
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent content={content} tips={tips} />
<TooltipContent
content={content}
tips={tips}
/>
</div>
) : null;
const handleMouseEnter = (e: React.MouseEvent) => {
clearTimers();
if (!isPinned) {
const effectiveDelay = Math.max(0, delay || 0);
openTimeoutRef.current = setTimeout(() => {
handleOpenChange(true);
}, effectiveDelay);
}
(children.props as any)?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent) => {
clearTimers();
openTimeoutRef.current = null;
if (!isPinned) {
handleOpenChange(false);
}
(children.props as any)?.onMouseLeave?.(e);
};
const handleClick = (e: React.MouseEvent) => {
// Toggle pin state on click
if (open) {
setIsPinned(!isPinned);
} else {
clearTimers();
handleOpenChange(true);
setIsPinned(true);
}
(children.props as any)?.onClick?.(e);
};
// Take the child element and add tooltip behavior to it
const childWithTooltipHandlers = React.cloneElement(children as any, {
// Keep track of the element for positioning
ref: (node: HTMLElement) => {
triggerRef.current = node;
// Don't break if the child already has a ref
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') {
originalRef(node);
} else if (originalRef && typeof originalRef === 'object') {
originalRef.current = node;
}
},
// Add mouse events to show/hide tooltip
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onClick: handleClick,
});
return (
<>
{childWithHandlers}
{childWithTooltipHandlers}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};
};

View File

@ -6,7 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { Tooltip } from "./Tooltip";
const viewOptionStyle = {
display: 'inline-flex',
@ -18,7 +18,7 @@ const viewOptionStyle = {
}
// Build view options showing text always
// Build view options showing text only for current view; others icon-only with tooltip
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
{
label: (
@ -35,37 +35,35 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
)}
</div>
<Tooltip content="Page Editor" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />
)}
</div>
</Tooltip>
),
value: "pageEditor",
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
</div>
<Tooltip content="Active Files" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />
)}
</div>
</Tooltip>
),
value: "fileEditor",
},

View File

@ -1,155 +1,177 @@
# Tooltip Component
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.
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.
---
## Features
## Highlights
* 🎯 **Smart Positioning**: Keeps tooltips within the viewport and aligns the arrow dynamically.
* 📱 **Sidebar Aware**: Purposebuilt logic for sidebar/navigation contexts.
* ♿ **Accessible**: Keyboard and screenreader friendly (`role="tooltip"`, `aria-describedby`, Escape to close, focus/blur support).
* 🎨 **Customizable**: Arrows, headers, rich JSX content, and structured tips.
* 🌙 **Themeable**: Uses CSS variables; supports dark mode out of the box.
* ⚡ **Efficient**: Memoized calculations and stable callbacks to minimize rerenders.
* 📜 **Scrollable Content**: When content exceeds max height.
* 📌 **ClicktoPin**: (Optional) Pin open; close via outside click or close button.
* 🔗 **LinkSafe**: Fully clickable links in descriptions, bullets, and custom content.
* 🖱️ **PointerFriendly**: Uses pointer events (works with mouse/pen/touch hover where applicable).
---
- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX
- 🌙 **Theme Support**: Built-in dark mode and theme variable support
- ⚡ **Performance**: Memoized calculations and efficient event handling
- 📜 **Scrollable**: Content area scrolls when content exceeds max height
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
## Behavior
### Default
### Default Behavior (Controlled)
- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering
- **Click**: Click the trigger to pin the tooltip open
- **Click tooltip**: Pins the tooltip to keep it open
- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned)
- **Click outside**: Unpins and closes the tooltip
- **Visual indicator**: Pinned tooltips have a blue border and close button
* **Hover/Focus**: Opens on pointer **enter** or when the trigger receives **focus** (respects optional `delay`).
* **Leave/Blur**: Closes on pointer **leave** (from trigger *and* tooltip) or when the trigger/tooltip **blurs** to the page—unless pinned.
* **Inside Tooltip**: Moving from trigger → tooltip keeps it open; moving out of both closes it (unless pinned).
* **Escape**: Press **Esc** to close.
### 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';
```
---
### Manual Control (Optional)
- Use `open` and `onOpenChange` props for complete external control
- Useful for complex state management or custom interaction patterns
## Basic Usage
```tsx
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
```
import { Tooltip } from '@/components/shared';
With structured tips and a header:
```tsx
<Tooltip
tips={[{
title: 'OCR Mode',
description: 'Choose how to process text in your documents.',
bullets: [
'<strong>Auto</strong> skips pages that already contain text.',
'<strong>Force</strong> re-processes every page.',
'<strong>Strict</strong> stops if text is found.',
"<a href='https://docs.example.com' target='_blank' rel='noreferrer'>Learn more</a>",
],
}]}
header={{ title: 'Basic Settings Overview', logo: <img src="/logo.svg" alt="Logo" /> }}
>
<button>Settings</button>
</Tooltip>
```
---
## API
### `<Tooltip />` Props
| Prop | Type | Default | Description |
| ---------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `children` | `ReactElement` | **required** | The trigger element. Receives ARIA and event handlers. |
| `content` | `ReactNode` | `undefined` | Custom JSX content rendered below any `tips`. |
| `tips` | `TooltipTip[]` | `undefined` | Structured content (title, description, bullets, optional body). |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic (no arrow in sidebar mode). |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Preferred placement (ignored if `sidebarTooltip` is `true`). |
| `offset` | `number` | `8` | Gap (px) between trigger and tooltip. |
| `maxWidth` | `number \| string` | `undefined` | Max width. If omitted and `sidebarTooltip` is true, defaults visually to \~`25rem`. |
| `minWidth` | `number \| string` | `undefined` | Min width. |
| `open` | `boolean` | `undefined` | Controlled open state. If provided, the component is controlled. |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback when open state would change. |
| `arrow` | `boolean` | `false` | Shows a directional arrow (suppressed in sidebar mode). |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into. |
| `header` | `{ title: string; logo?: ReactNode }` | `undefined` | Optional header with title and logo. |
| `delay` | `number` | `0` | Hover/focus open delay in ms. |
| `containerStyle` | `React.CSSProperties` | `{}` | Inline style overrides for the tooltip container. |
| `pinOnClick` | `boolean` | `false` | Clicking the trigger pins the tooltip open. |
| `closeOnOutside` | `boolean` | `true` | When not pinned, clicking outside closes the tooltip. Always closes when pinned and clicking outside both trigger & tooltip. |
### `TooltipTip`
```ts
export interface TooltipTip {
title?: string; // Optional pill label
description?: string; // HTML allowed (e.g., <a>)
bullets?: string[]; // HTML allowed in each string
body?: React.ReactNode; // Optional custom JSX
function MyComponent() {
return (
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
);
}
```
---
## API Reference
## Accessibility
### Props
* 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.
| 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 |
> Ensure custom triggers remain focusable (e.g., `button`, `a`, or add `tabIndex=0`).
### 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
}
```
## Interaction Details
## Usage Examples
* **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
### Default Behavior (Recommended)
```tsx
<Tooltip content="Arrow tooltip" arrow position="top">
<button>Arrow tooltip</button>
// Simple tooltip with hover and click-to-pin
<Tooltip content="This tooltip appears on hover and pins on click">
<button>Hover me</button>
</Tooltip>
// Structured content with tips
<Tooltip
tips={[
{
title: "OCR Mode",
description: "Choose how to process text in your documents.",
bullets: [
"<strong>Auto</strong> skips pages that already contain text.",
"<strong>Force</strong> re-processes every page.",
"<strong>Strict</strong> stops if text is found.",
"<a href='https://docs.example.com' target='_blank'>Learn more</a>"
]
}
]}
header={{
title: "Basic Settings Overview",
logo: <img src="/logo.svg" alt="Logo" />
}}
>
<button>Settings</button>
</Tooltip>
```
### Optional Hover Delay
```tsx
<Tooltip content="Appears after 1s" delay={1000}>
<button>Delayed</button>
// Show after a 1s hover
<Tooltip content="Appears after a long hover" delay={1000} />
// Custom long-hover duration (2 seconds)
<Tooltip content="Appears after 2s" delay={2000} />
```
### Custom JSX Content
```tsx
<Tooltip
content={
<div>
<h3>Custom Content</h3>
<p>Any JSX you want here</p>
<button>Action</button>
<a href="https://example.com">External link</a>
</div>
}
>
<button>Custom tooltip</button>
</Tooltip>
```
### Mixed Content (Tips + Custom JSX)
```tsx
<Tooltip
tips={[
{ title: "Section", description: "Description" }
]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
### Sidebar Tooltips
```tsx
// For items in a sidebar/navigation
<Tooltip
content="This tooltip appears to the right of the sidebar"
sidebarTooltip={true}
>
<div className="sidebar-item">
📁 File Manager
</div>
</Tooltip>
```
### With Arrows
```tsx
<Tooltip
content="Tooltip with arrow pointing to trigger"
arrow={true}
position="top"
>
<button>Arrow tooltip</button>
</Tooltip>
```
@ -158,55 +180,63 @@ export interface TooltipTip {
```tsx
function ManualControlTooltip() {
const [open, setOpen] = useState(false);
return (
<Tooltip content="Fully controlled tooltip" open={open} onOpenChange={setOpen}>
<button onClick={() => setOpen(!open)}>Toggle tooltip</button>
<Tooltip
content="Fully controlled tooltip"
open={open}
onOpenChange={setOpen}
>
<button onClick={() => setOpen(!open)}>
Toggle tooltip
</button>
</Tooltip>
);
}
```
### Sidebar Tooltip
## Click-to-Pin Interaction
```tsx
<Tooltip content="Appears to the right of the sidebar" sidebarTooltip>
<div className="sidebar-item">📁 File Manager</div>
</Tooltip>
```
### How to Use (Default Behavior)
1. **Hover** over the trigger element to show the tooltip
2. **Click** the trigger element to pin the tooltip open
3. **Click** the red X button in the top-right corner to close
4. **Click** anywhere outside the tooltip to close
5. **Click** the trigger again to toggle pin state
### Mixed Content
### Visual States
- **Unpinned**: Normal tooltip appearance
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
```tsx
<Tooltip
tips={[{ title: 'Section', description: 'Description' }]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
## Link Support
---
The tooltip fully supports clickable links in all content areas:
## Positioning Notes
- **Descriptions**: Use `<a href="...">` in description strings
- **Bullets**: Use `<a href="...">` in bullet point strings
- **Body**: Use JSX `<a>` elements in the body ReactNode
- **Content**: Use JSX `<a>` elements in custom content
* 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.
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
---
## Positioning Logic
## Caveats & Tips
### Regular Tooltips
- Uses the `position` prop to determine initial placement
- Automatically clamps to viewport boundaries
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
* 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).
## Timing Details
---
- 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.
## Changelog (since previous README)
* Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`).
* Clarified outsideclick behavior for pinned vs unpinned.
* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
* Removed references to nonexistent props (e.g., `delayAppearance`).
* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).
### Sidebar Tooltips
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
- Vertical positioning follows the trigger but clamps to viewport
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`)
- **No arrows** - sidebar tooltips don't show arrows

View File

@ -7,7 +7,7 @@ export const buildAddStampFormData = (parameters: AddStampParameters, file: File
const formData = new FormData();
formData.append('fileInput', file);
formData.append('pageNumbers', parameters.pageNumbers);
formData.append('customMargin', parameters.customMargin || 'medium');
formData.append('customMargin', 'medium');
formData.append('position', String(parameters.position));
const effectiveFontSize = parameters.fontSize;
formData.append('fontSize', String(effectiveFontSize));
@ -34,6 +34,7 @@ export const addStampOperationConfig = {
buildFormData: buildAddStampFormData,
operationType: 'stamp',
endpoint: '/api/v1/misc/add-stamp',
filePrefix: 'stamped_',
defaultParameters,
} as const;
@ -42,6 +43,7 @@ export const useAddStampOperation = () => {
return useToolOperation<AddStampParameters>({
...addStampOperationConfig,
filePrefix: t('stamp.filenamePrefix', 'stamped') + '_',
getErrorMessage: createStandardErrorHandler(
t('AddStampRequest.error.failed', 'An error occurred while adding stamp to the PDF.')
),

View File

@ -1,99 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,74 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,75 +0,0 @@
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

@ -1,39 +0,0 @@
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

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

View File

@ -1,108 +0,0 @@
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

@ -1,44 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,34 +0,0 @@
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

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

View File

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

View File

@ -1,144 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,168 +0,0 @@
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

@ -1,136 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,101 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,160 +0,0 @@
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

@ -1,67 +0,0 @@
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

@ -1,123 +0,0 @@
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, useRef } from 'react';
import { useEffect, useCallback } from 'react';
import { useFileSelection } from '../../../contexts/FileContext';
import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool';
@ -45,7 +45,6 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
// File selection
const { selectedFiles } = useFileSelection();
const previousFileCount = useRef(selectedFiles.length);
// Tool-specific hooks
const params = useParams();
@ -68,18 +67,6 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
}
}, [selectedFiles.length]);
// Reset parameters when transitioning from 0 files to at least 1 file
useEffect(() => {
const currentFileCount = selectedFiles.length;
const prevFileCount = previousFileCount.current;
if (prevFileCount === 0 && currentFileCount > 0) {
params.resetParameters();
}
previousFileCount.current = currentFileCount;
}, [selectedFiles.length]);
// Standard handlers
const handleExecute = useCallback(async () => {
try {

View File

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

View File

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

View File

@ -181,11 +181,6 @@
--information-text-bg: #eaeaea;
--information-text-color: #5e5e5e;
/* Bulk selection panel specific colors (light mode) */
--bulk-panel-bg: #ffffff; /* white background for parent container */
--bulk-card-bg: #ffffff; /* white background for cards */
--bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
}
[data-mantine-color-scheme="dark"] {
@ -332,13 +327,6 @@
--information-text-bg: #292e34;
--information-text-color: #ececec;
/* Bulk selection panel specific colors (dark mode) */
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
--bulk-card-border: var(--border-default); /* default border for cards and buttons */
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
}
/* Dropzone drop state styling */

View File

@ -323,23 +323,6 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
)}
{/* Margin selection appears when using quick grid (and for text stamps) */}
{(params.parameters.stampType === 'text' || (params.parameters.stampType === 'image' && quickPositionModeSelected)) && (
<Select
label={t('AddStampRequest.margin', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('margin.small', 'Small') },
{ value: 'medium', label: t('margin.medium', 'Medium') },
{ value: 'large', label: t('margin.large', 'Large') },
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
]}
disabled={endpointLoading}
/>
)}
{/* Unified preview; when in quick mode, overlay grid inside preview */}
<StampPreview
parameters={params.parameters}

View File

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

View File

@ -1,70 +0,0 @@
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

@ -1,64 +0,0 @@
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

@ -1,57 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,77 +0,0 @@
## 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

@ -1,253 +0,0 @@
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

@ -1,413 +0,0 @@
// 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

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