diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 342f0512f..f16d1f9cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.14.0", "@iconify/react": "^6.0.0", "@mantine/core": "^8.0.1", + "@mantine/dates": "^8.0.1", "@mantine/dropzone": "^8.0.1", "@mantine/hooks": "^8.0.1", "@mui/icons-material": "^7.1.0", @@ -1653,6 +1654,22 @@ "react-dom": "^18.x || ^19.x" } }, + "node_modules/@mantine/dates": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz", + "integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@mantine/core": "8.0.1", + "@mantine/hooks": "8.0.1", + "dayjs": ">=1.0.0", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, "node_modules/@mantine/dropzone": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz", @@ -4367,6 +4384,13 @@ "node": ">=18" } }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0b14a8ffc..214830cd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@emotion/styled": "^11.14.0", "@iconify/react": "^6.0.0", "@mantine/core": "^8.0.1", + "@mantine/dates": "^8.0.1", "@mantine/dropzone": "^8.0.1", "@mantine/hooks": "^8.0.1", "@mui/icons-material": "^7.1.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 49afabb8c..9d53d472a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -797,9 +797,27 @@ "rotate": { "tags": "server side", "title": "Rotate PDF", - "header": "Rotate PDF", - "selectAngle": "Select rotation angle (in multiples of 90 degrees):", - "submit": "Rotate" + "submit": "Apply Rotation", + "error": { + "failed": "An error occurred while rotating the PDF." + }, + "preview": { + "title": "Rotation Preview" + }, + "rotateLeft": "Rotate Anticlockwise", + "rotateRight": "Rotate Clockwise", + "tooltip": { + "header": { + "title": "Rotate Settings Overview" + }, + "description": { + "text": "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation." + }, + "controls": { + "title": "Controls", + "text": "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees." + } + } }, "convert": { "title": "Convert", @@ -1125,15 +1143,46 @@ "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", - "pageNumbers": "Pages to Remove", - "pageNumbersPlaceholder": "e.g. 1,3,5-7", - "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "pageNumbers": { + "label": "Pages to Remove", + "placeholder": "e.g., 1,3,5-8,10", + "error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)" + }, "filenamePrefix": "pages_removed", "files": { "placeholder": "Select a PDF file in the main view to get started" }, "settings": { - "title": "Page Selection" + "title": "Settings" + }, + "tooltip": { + "header": { + "title": "Remove Pages Settings" + }, + "pageNumbers": { + "title": "Page Selection", + "text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.", + "bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)", + "bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)", + "bullet3": "Mathematical: 2n+1 (removes odd pages)", + "bullet4": "Open ranges: 5- (removes from page 5 to end)" + }, + "examples": { + "title": "Common Examples", + "text": "Here are some common page selection patterns:", + "bullet1": "Remove first page: 1", + "bullet2": "Remove last 3 pages: -3", + "bullet3": "Remove every other page: 2n", + "bullet4": "Remove specific scattered pages: 1,5,10,15" + }, + "safety": { + "title": "Safety Tips", + "text": "Important considerations when removing pages:", + "bullet1": "Always preview your selection before processing", + "bullet2": "Keep a backup of your original file", + "bullet3": "Page numbers start from 1, not 0", + "bullet4": "Invalid page numbers will be ignored" + } }, "error": { "failed": "An error occurred whilst removing pages." @@ -1145,9 +1194,7 @@ }, "pageSelection": { "tooltip": { - "header": { - "title": "Page Selection Guide" - }, + "header": { "title": "Page Selection Guide" }, "basic": { "title": "Basic Usage", "text": "Select specific pages from your PDF document using simple syntax.", @@ -1164,7 +1211,74 @@ "bullet1": "Page numbers start from 1 (not 0)", "bullet2": "Spaces are automatically removed", "bullet3": "Invalid expressions are ignored" + }, + "syntax": { + "title": "Syntax Basics", + "text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.", + "bullets": { + "numbers": "Numbers/ranges: 5, 10-20", + "keywords": "Keywords: odd, even", + "progressions": "Progressions: 3n, 4n+1" + } + }, + "operators": { + "title": "Operators", + "text": "AND has higher precedence than comma. NOT applies within the document range.", + "and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)", + "comma": "Comma: , or | — combine selections (e.g., 1-10, 20)", + "not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" + }, + "examples": { "title": "Examples" } + } + }, + "bulkSelection": { + "header": { "title": "Page Selection Guide" }, + "syntax": { + "title": "Syntax Basics", + "text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.", + "bullets": { + "numbers": "Numbers/ranges: 5, 10-20", + "keywords": "Keywords: odd, even", + "progressions": "Progressions: 3n, 4n+1" } + }, + "operators": { + "title": "Operators", + "text": "AND has higher precedence than comma. NOT applies within the document range.", + "and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)", + "comma": "Comma: , or | — combine selections (e.g., 1-10, 20)", + "not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" + }, + "examples": { + "title": "Examples", + "first50": "First 50", + "last50": "Last 50", + "every3rd": "Every 3rd", + "oddWithinExcluding": "Odd within 1-20 excluding 5-7", + "combineSets": "Combine sets" + }, + "firstNPages": { + "title": "First N Pages", + "placeholder": "Number of pages" + }, + "lastNPages": { + "title": "Last N Pages", + "placeholder": "Number of pages" + }, + "everyNthPage": { + "title": "Every Nth Page", + "placeholder": "Step size" + }, + "range": { + "title": "Range", + "fromPlaceholder": "From", + "toPlaceholder": "To" + }, + "keywords": { + "title": "Keywords" + }, + "advanced": { + "title": "Advanced" } }, "compressPdfs": { @@ -1189,24 +1303,127 @@ }, "changeMetadata": { "tags": "Title,author,date,creation,time,publisher,producer,stats", - "title": "Change Metadata", "header": "Change Metadata", - "selectText": { - "1": "Please edit the variables you wish to change", - "2": "Delete all metadata", - "3": "Show Custom Metadata:", - "4": "Other Metadata:", - "5": "Add Custom Metadata Entry" + "submit": "Change", + "filenamePrefix": "metadata", + "settings": { + "title": "Metadata Settings" }, - "author": "Author:", - "creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):", - "creator": "Creator:", - "keywords": "Keywords:", - "modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):", - "producer": "Producer:", - "subject": "Subject:", - "trapped": "Trapped:", - "submit": "Change" + "standardFields": { + "title": "Standard Fields" + }, + "deleteAll": { + "label": "Remove Existing Metadata", + "checkbox": "Delete all metadata" + }, + "title": { + "label": "Title", + "placeholder": "Document title" + }, + "author": { + "label": "Author", + "placeholder": "Document author" + }, + "subject": { + "label": "Subject", + "placeholder": "Document subject" + }, + "keywords": { + "label": "Keywords", + "placeholder": "Document keywords" + }, + "creator": { + "label": "Creator", + "placeholder": "Document creator" + }, + "producer": { + "label": "Producer", + "placeholder": "Document producer" + }, + "dates": { + "title": "Date Fields" + }, + "creationDate": { + "label": "Creation Date", + "placeholder": "Creation date" + }, + "modificationDate": { + "label": "Modification Date", + "placeholder": "Modification date" + }, + "trapped": { + "label": "Trapped Status", + "unknown": "Unknown", + "true": "True", + "false": "False" + }, + "advanced": { + "title": "Advanced Options" + }, + "customFields": { + "title": "Custom Metadata", + "description": "Add custom metadata fields to the document", + "add": "Add Field", + "key": "Key", + "keyPlaceholder": "Custom key", + "value": "Value", + "valuePlaceholder": "Custom value", + "remove": "Remove" + }, + "results": { + "title": "Updated PDFs" + }, + "error": { + "failed": "An error occurred while changing the PDF metadata." + }, + "tooltip": { + "header": { + "title": "PDF Metadata Overview" + }, + "standardFields": { + "title": "Standard Fields", + "text": "Common PDF metadata fields that describe the document.", + "bullet1": "Title: Document name or heading", + "bullet2": "Author: Person who created the document", + "bullet3": "Subject: Brief description of content", + "bullet4": "Keywords: Search terms for the document", + "bullet5": "Creator/Producer: Software used to create the PDF" + }, + "dates": { + "title": "Date Fields", + "text": "When the document was created and modified.", + "bullet1": "Creation Date: When original document was made", + "bullet2": "Modification Date: When last changed" + }, + "options": { + "title": "Additional Options", + "text": "Custom fields and privacy controls.", + "bullet1": "Custom Metadata: Add your own key-value pairs", + "bullet2": "Trapped Status: High-quality printing setting", + "bullet3": "Delete All: Remove all metadata for privacy" + }, + "deleteAll": { + "title": "Remove Existing Metadata", + "text": "Complete metadata deletion to ensure privacy." + }, + "customFields": { + "title": "Custom Metadata", + "text": "Add your own custom key-value metadata pairs.", + "bullet1": "Add any custom fields relevant to your document", + "bullet2": "Examples: Department, Project, Version, Status", + "bullet3": "Both key and value are required for each entry" + }, + "advanced": { + "title": "Advanced Options", + "trapped": { + "title": "Trapped Status", + "description": "Indicates if document is prepared for high-quality printing.", + "bullet1": "True: Document has been trapped for printing", + "bullet2": "False: Document has not been trapped", + "bullet3": "Unknown: Trapped status is not specified" + } + } + } }, "fileToPDF": { "tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint", @@ -1492,11 +1709,46 @@ "tags": "cleanup,streamline,non-content,organize", "title": "Remove Blanks", "header": "Remove Blank Pages", - "threshold": "Pixel Whiteness Threshold:", - "thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.", - "whitePercent": "White Percent (%):", - "whitePercentDesc": "Percent of page that must be 'white' pixels to be removed", - "submit": "Remove Blanks" + "settings": { + "title": "Settings" + }, + "threshold": { + "label": "Pixel Whiteness Threshold" + }, + "whitePercent": { + "label": "White Percentage Threshold", + "unit": "%" + }, + "includeBlankPages": { + "label": "Include detected blank pages" + }, + "tooltip": { + "header": { + "title": "Remove Blank Pages Settings" + }, + "threshold": { + "title": "Pixel Whiteness Threshold", + "text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.", + "bullet1": "0 = Pure black (most restrictive)", + "bullet2": "128 = Medium grey", + "bullet3": "255 = Pure white (least restrictive)" + }, + "whitePercent": { + "title": "White Percentage Threshold", + "text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.", + "bullet1": "Lower values (e.g., 80%) = More pages removed", + "bullet2": "Higher values (e.g., 95%) = Only very blank pages removed", + "bullet3": "Use higher values for documents with light backgrounds" + }, + "includeBlankPages": { + "title": "Include Detected Blank Pages", + "text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.", + "bullet1": "Useful for reviewing what was removed", + "bullet2": "Helps verify the detection accuracy", + "bullet3": "Can be disabled to reduce output file size" + } + }, + "submit": "Remove blank pages" }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 4b93181b8..a58268664 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -745,15 +745,46 @@ "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", - "pageNumbers": "Pages to Remove", - "pageNumbersPlaceholder": "e.g. 1,3,5-7", - "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "pageNumbers": { + "label": "Pages to Remove", + "placeholder": "e.g., 1,3,5-8,10", + "error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)" + }, "filenamePrefix": "pages_removed", "files": { "placeholder": "Select a PDF file in the main view to get started" }, "settings": { - "title": "Page Selection" + "title": "Settings" + }, + "tooltip": { + "header": { + "title": "Remove Pages Settings" + }, + "pageNumbers": { + "title": "Page Selection", + "text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.", + "bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)", + "bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)", + "bullet3": "Mathematical: 2n+1 (removes odd pages)", + "bullet4": "Open ranges: 5- (removes from page 5 to end)" + }, + "examples": { + "title": "Common Examples", + "text": "Here are some common page selection patterns:", + "bullet1": "Remove first page: 1", + "bullet2": "Remove last 3 pages: -3", + "bullet3": "Remove every other page: 2n", + "bullet4": "Remove specific scattered pages: 1,5,10,15" + }, + "safety": { + "title": "Safety Tips", + "text": "Important considerations when removing pages:", + "bullet1": "Always preview your selection before processing", + "bullet2": "Keep a backup of your original file", + "bullet3": "Page numbers start from 1, not 0", + "bullet4": "Invalid page numbers will be ignored" + } }, "error": { "failed": "An error occurred while removing pages." @@ -807,7 +838,74 @@ "bullet1": "Page numbers start from 1 (not 0)", "bullet2": "Spaces are automatically removed", "bullet3": "Invalid expressions are ignored" + }, + "syntax": { + "title": "Syntax Basics", + "text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.", + "bullets": { + "numbers": "Numbers/ranges: 5, 10-20", + "keywords": "Keywords: odd, even", + "progressions": "Progressions: 3n, 4n+1" + } + }, + "operators": { + "title": "Operators", + "text": "AND has higher precedence than comma. NOT applies within the document range.", + "and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)", + "comma": "Comma: , or | — combine selections (e.g., 1-10, 20)", + "not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" + }, + "examples": { "title": "Examples" } + } + }, + "bulkSelection": { + "header": { "title": "Page Selection Guide" }, + "syntax": { + "title": "Syntax Basics", + "text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.", + "bullets": { + "numbers": "Numbers/ranges: 5, 10-20", + "keywords": "Keywords: odd, even", + "progressions": "Progressions: 3n, 4n+1" } + }, + "operators": { + "title": "Operators", + "text": "AND has higher precedence than comma. NOT applies within the document range.", + "and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)", + "comma": "Comma: , or | — combine selections (e.g., 1-10, 20)", + "not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" + }, + "examples": { + "title": "Examples", + "first50": "First 50", + "last50": "Last 50", + "every3rd": "Every 3rd", + "oddWithinExcluding": "Odd within 1-20 excluding 5-7", + "combineSets": "Combine sets" + }, + "firstNPages": { + "title": "First N Pages", + "placeholder": "Number of pages" + }, + "lastNPages": { + "title": "Last N Pages", + "placeholder": "Number of pages" + }, + "everyNthPage": { + "title": "Every Nth Page", + "placeholder": "Step size" + }, + "range": { + "title": "Range", + "fromPlaceholder": "From", + "toPlaceholder": "To" + }, + "keywords": { + "title": "Keywords" + }, + "advanced": { + "title": "Advanced" } }, "compressPdfs": { @@ -1013,11 +1111,46 @@ "tags": "cleanup,streamline,non-content,organize", "title": "Remove Blanks", "header": "Remove Blank Pages", - "threshold": "Pixel Whiteness Threshold:", - "thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.", - "whitePercent": "White Percent (%):", - "whitePercentDesc": "Percent of page that must be 'white' pixels to be removed", - "submit": "Remove Blanks" + "settings": { + "title": "Settings" + }, + "threshold": { + "label": "Pixel Whiteness Threshold" + }, + "whitePercent": { + "label": "White Percentage Threshold", + "unit": "%" + }, + "includeBlankPages": { + "label": "Include detected blank pages" + }, + "tooltip": { + "header": { + "title": "Remove Blank Pages Settings" + }, + "threshold": { + "title": "Pixel Whiteness Threshold", + "text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.", + "bullet1": "0 = Pure black (most restrictive)", + "bullet2": "128 = Medium gray", + "bullet3": "255 = Pure white (least restrictive)" + }, + "whitePercent": { + "title": "White Percentage Threshold", + "text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.", + "bullet1": "Lower values (e.g., 80%) = More pages removed", + "bullet2": "Higher values (e.g., 95%) = Only very blank pages removed", + "bullet3": "Use higher values for documents with light backgrounds" + }, + "includeBlankPages": { + "title": "Include Detected Blank Pages", + "text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.", + "bullet1": "Useful for reviewing what was removed", + "bullet2": "Helps verify the detection accuracy", + "bullet3": "Can be disabled to reduce output file size" + } + }, + "submit": "Remove blank pages" }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", diff --git a/frontend/src/components/pageEditor/BulkSelectionPanel.tsx b/frontend/src/components/pageEditor/BulkSelectionPanel.tsx index cc9b8d5f5..16ec84453 100644 --- a/frontend/src/components/pageEditor/BulkSelectionPanel.tsx +++ b/frontend/src/components/pageEditor/BulkSelectionPanel.tsx @@ -1,12 +1,16 @@ -import React from 'react'; -import { Group, TextInput, Button, Text } from '@mantine/core'; +import { useState, useEffect } from 'react'; +import classes from './bulkSelectionPanel/BulkSelectionPanel.module.css'; +import { parseSelectionWithDiagnostics } from '../../utils/bulkselection/parseSelection'; +import PageSelectionInput from './bulkSelectionPanel/PageSelectionInput'; +import SelectedPagesDisplay from './bulkSelectionPanel/SelectedPagesDisplay'; +import AdvancedSelectionPanel from './bulkSelectionPanel/AdvancedSelectionPanel'; interface BulkSelectionPanelProps { csvInput: string; setCsvInput: (value: string) => void; selectedPageIds: string[]; displayDocument?: { pages: { id: string; pageNumber: number }[] }; - onUpdatePagesFromCSV: () => void; + onUpdatePagesFromCSV: (override?: string) => void; } const BulkSelectionPanel = ({ @@ -16,31 +20,56 @@ const BulkSelectionPanel = ({ displayDocument, onUpdatePagesFromCSV, }: BulkSelectionPanelProps) => { + const [syntaxError, setSyntaxError] = useState(null); + const [advancedOpened, setAdvancedOpened] = useState(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 ( - <> - - setCsvInput(e.target.value)} - placeholder="1,3,5-10" - label="Page Selection" - onBlur={onUpdatePagesFromCSV} - onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()} - style={{ flex: 1 }} - /> - - - {selectedPageIds.length > 0 && ( - - 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(', ') : ''}) - - )} - +
+ + + + + +
); }; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 2c85d8f81..8939f7db4 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -171,7 +171,8 @@ const PageEditor = ({ }, () => splitPositions, setSplitPositions, - () => getPageNumbersFromIds(selectedPageIds) + () => getPageNumbersFromIds(selectedPageIds), + closePdf ); undoManagerRef.current.executeCommand(deleteCommand); } @@ -228,7 +229,8 @@ const PageEditor = ({ }, () => splitPositions, setSplitPositions, - () => selectedPageNumbers + () => selectedPageNumbers, + closePdf ); undoManagerRef.current.executeCommand(deleteCommand); }, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]); @@ -246,7 +248,8 @@ const PageEditor = ({ }, () => splitPositions, setSplitPositions, - () => getPageNumbersFromIds(selectedPageIds) + () => getPageNumbersFromIds(selectedPageIds), + closePdf ); undoManagerRef.current.executeCommand(deleteCommand); }, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]); diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx b/frontend/src/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx new file mode 100644 index 000000000..e61caab6a --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Flex } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import classes from './BulkSelectionPanel.module.css'; +import { + appendExpression, + insertOperatorSmart, + firstNExpression, + lastNExpression, + everyNthExpression, + rangeExpression, + LogicalOperator, +} from './BulkSelection'; +import SelectPages from './SelectPages'; +import OperatorsSection from './OperatorsSection'; + +interface AdvancedSelectionPanelProps { + csvInput: string; + setCsvInput: (value: string) => void; + onUpdatePagesFromCSV: (override?: string) => void; + maxPages: number; + advancedOpened?: boolean; +} + +const AdvancedSelectionPanel = ({ + csvInput, + setCsvInput, + onUpdatePagesFromCSV, + maxPages, + advancedOpened, +}: AdvancedSelectionPanelProps) => { + const { t } = useTranslation(); + const [rangeEnd, setRangeEnd] = useState(''); + + 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 && ( +
+
+ {/* Cards row */} + + + + + + + + + + + {/* Operators row at bottom */} + +
+
+ )} + + ); +}; + +export default AdvancedSelectionPanel; diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelection.ts b/frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelection.ts new file mode 100644 index 000000000..d99b03a51 --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelection.ts @@ -0,0 +1,136 @@ +// Pure helper utilities for the BulkSelectionPanel UI + +export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd'; + +// Returns a new CSV expression with expr appended. +// If current ends with an operator token, expr is appended directly. +// Otherwise, it is joined with " or ". +export function appendExpression(currentInput: string, expr: string): string { + const current = (currentInput || '').trim(); + if (!current) return expr; + const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current); + // Add space if operator doesn't already have one + if (endsWithOperator) { + const needsSpace = !current.endsWith(' '); + return `${current}${needsSpace ? ' ' : ''}${expr}`; + } + return `${current} or ${expr}`; +} + +// Smartly inserts/normalizes a logical operator at the end of the current input. +// Produces a trailing space to allow the next token to be typed naturally. +export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string { + const text = (currentInput || '').trim(); + // Handle 'even' and 'odd' as page selection expressions, not logical operators + if (op === 'even' || op === 'odd') { + if (text.length === 0) return `${op} `; + // If current input ends with a logical operator, append the page selection with proper spacing + const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text); + if (endsWithOperator) { + // Add space if the operator doesn't already have one + const needsSpace = !text.endsWith(' '); + return `${text}${needsSpace ? ' ' : ''}${op} `; + } + return `${text} or ${op} `; + } + + if (text.length === 0) return `${op} `; + + // Extract up to the last two operator tokens (words or symbols) from the end + const tokens: string[] = []; + let rest = text; + for (let i = 0; i < 2; i++) { + const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i); + if (!m || m.index === undefined) break; + const raw = m[1].toLowerCase(); + const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw; + tokens.unshift(word); + rest = rest.slice(0, m.index).trimEnd(); + } + + const emit = (base: string, phrase: string) => `${base} ${phrase} `; + const click = op; // desired operator + + if (tokens.length === 0) { + return emit(text, click); + } + + // Normalize to allowed set + const phrase = tokens.join(' '); + const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']); + + // Helpers for transitions from a single trailing token + const fromSingle = (t: string): string => { + if (t === 'and') { + if (click === 'and') return 'and'; + if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or' + return 'and not'; + } + if (t === 'or') { + if (click === 'and') return 'and'; + if (click === 'or') return 'or'; + return 'or not'; + } + // t === 'not' + if (click === 'and') return 'and'; + if (click === 'or') return 'or'; + return 'not'; + }; + + // From combined phrase + const fromCombo = (p: string): string => { + if (p === 'and not') { + if (click === 'not') return 'and not'; + if (click === 'and') return 'and'; + if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or' + return 'and not'; + } + if (p === 'or not') { + if (click === 'not') return 'or not'; + if (click === 'or') return 'or'; + if (click === 'and') return 'and'; // 'or not and' is invalid, so just use 'and' + return 'or not'; + } + // Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op + return click; + }; + + const base = rest.trim(); + const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase); + if (!allowed.has(nextPhrase)) { + return emit(base, click); + } + return emit(base, nextPhrase); +} + +// Expression builders for Advanced actions +export function firstNExpression(n: number, maxPages: number): string | null { + if (!Number.isFinite(n) || n <= 0) return null; + const end = Math.min(maxPages, Math.max(1, Math.floor(n))); + return `1-${end}`; +} + +export function lastNExpression(n: number, maxPages: number): string | null { + if (!Number.isFinite(n) || n <= 0) return null; + const count = Math.max(1, Math.floor(n)); + const start = Math.max(1, maxPages - count + 1); + if (maxPages <= 0) return null; + return `${start}-${maxPages}`; +} + +export function everyNthExpression(n: number): string | null { + if (!Number.isFinite(n) || n <= 0) return null; + return `${Math.max(1, Math.floor(n))}n`; +} + +export function rangeExpression(start: number, end: number, maxPages: number): string | null { + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + let s = Math.floor(start); + let e = Math.floor(end); + if (s > e) [s, e] = [e, s]; + s = Math.max(1, s); + e = maxPages > 0 ? Math.min(maxPages, e) : e; + return `${s}-${e}`; +} + + diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css b/frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css new file mode 100644 index 000000000..a66bc1a6a --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css @@ -0,0 +1,295 @@ +.panelGroup { + max-width: 100%; + flex-wrap: wrap; + min-width: 24rem; +} + +.textInput { + flex: 1; + max-width: 100%; +} + +.dropdownContainer { + margin-top: 0.5rem; +} + +.menuDropdown { + min-width: 22.5rem; +} + +.dropdownContent { + display: flex; + gap: 0.75rem; +} + +.leftCol { + flex: 1 1 auto; + min-width: 0; + max-width: calc(100% - 8rem - 0.75rem); + overflow: hidden; +} + +.rightCol { + width: 8rem; + border-left: 0.0625rem solid var(--border-default); + padding-left: 0.75rem; + display: flex; + flex-direction: column; +} + +.operatorGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.operatorChip { + width: 100%; + border-radius: 1.25rem; + border: 0.0625rem solid var(--bulk-card-border); + background-color: var(--bulk-card-bg); + color: var(--text-primary); + transition: all 0.2s ease; + min-height: 2rem; +} + +.operatorChip:hover:not(:disabled) { + border-color: var(--bulk-card-hover-border); + background-color: var(--hover-bg); + transform: translateY(-0.0625rem); + box-shadow: var(--shadow-sm); +} + +.operatorChip:active:not(:disabled) { + transform: translateY(0); + box-shadow: var(--shadow-xs); +} + +.operatorChip:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +:global([data-mantine-color-scheme='dark']) .operatorChip { + background-color: var(--bulk-card-bg); + border-color: var(--bulk-card-border); + color: var(--text-primary); +} + +:global([data-mantine-color-scheme='dark']) .operatorChip:hover:not(:disabled) { + background-color: var(--hover-bg); + border-color: var(--bulk-card-hover-border); + color: var(--text-primary); +} + + .dropdownHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-bottom: 0.0625rem solid var(--border-default); + margin-bottom: 0.5rem; + } + + .closeButton { + min-width: 1.5rem; + height: 1.5rem; + padding: 0; + font-size: 1.25rem; + font-weight: bold; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } + +.menuItemRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.chevron { + color: var(--text-muted); +} + +/* Icon-based chevrons */ +.chevronIcon { + transition: transform 150ms ease; + display: inline-flex; + align-items: center; +} + +.chevronDown { + transform: rotate(90deg); +} + +.chevronUp { + transform: rotate(270deg); +} + +.inlineRow { + padding: 0.75rem 0.5rem; +} + +.inlineRowCompact { + padding: 0.5rem 0.5rem 0.75rem 0.5rem; +} + +.menuItemCloseHover { + background-color: var(--text-brand-accent); + opacity: 0.1; + transition: background-color 150ms ease; +} + +:global([data-mantine-color-scheme='dark']) .menuItemCloseHover { + background-color: var(--text-brand-accent); + opacity: 0.2; +} + +.selectedList { + max-height: 8rem; + overflow: auto; + background-color: var(--bg-raised); + border: 0.0625rem solid var(--border-default); + border-radius: 0.75rem; + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; + min-width: 24rem; + color: var(--text-primary); +} + +.selectedText { + word-break: break-word; + max-width: 100%; +} + +.advancedSection { + margin-top: 0.5rem; + min-width: 24rem; +} + +.advancedHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-bottom: 0.0625rem solid var(--border-default); + margin-bottom: 0.5rem; +} + +.advancedContent { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.advancedItem { + padding: 0.5rem; + cursor: pointer; + border-radius: 0.25rem; + transition: background-color 150ms ease; +} + +.advancedItem:hover { + background-color: var(--hover-bg); +} + +:global([data-mantine-color-scheme='dark']) .advancedItem:hover { + background-color: var(--hover-bg); +} + +.advancedCard { + background-color: var(--bulk-card-bg); + border: none; + border-radius: 0.75rem; + padding: 0.25rem; + margin-bottom: 0.5rem; + width: 100%; + box-sizing: border-box; + color: var(--text-primary); +} + +:global([data-mantine-color-scheme='dark']) .advancedCard { + background-color: var(--bulk-card-bg); +} + +.inputGroup { + width: 100%; +} + +.fullWidthInput { + flex: 1; +} + +.applyButton { + min-width: 4rem; + flex-shrink: 0; +} + +/* Style inputs and buttons within advanced cards to match bg-raised */ +.advancedCard :global(.mantine-NumberInput-input) { + background-color: var(--bg-raised) !important; + border-color: var(--border-default) !important; + color: var(--text-primary) !important; +} + +.advancedCard :global(.mantine-Button-root) { + background-color: var(--bg-raised) !important; + border-color: var(--border-default) !important; + color: var(--text-primary) !important; +} + +.advancedCard :global(.mantine-Button-root:hover) { + background-color: var(--hover-bg) !important; + border-color: var(--border-strong) !important; +} + +/* Error helper text above the input */ +.errorText { + margin-top: 0.25rem; + color: var(--text-brand-accent); +} + +/* Dark-mode adjustments */ +:global([data-mantine-color-scheme='dark']) .selectedList { + background-color: var(--bg-raised); +} + +/* Small screens: allow the section to shrink instead of enforcing a large min width */ +@media (max-width: 480px) { + .panelGroup, + .selectedList, + .advancedSection, + .panelContainer { + min-width: 0; + } +} + +/* Outermost panel container scrolling */ +.panelContainer { + max-height: 95vh; + overflow: auto; + background-color: var(--bulk-panel-bg); + color: var(--text-primary); + border-radius: 0.5rem; +} + +/* Override Mantine Popover dropdown background */ +:global(.mantine-Popover-dropdown) { + background-color: var(--bulk-panel-bg) !important; + border-color: var(--bulk-card-border) !important; + color: var(--text-primary) !important; +} + +/* Override Mantine Switch outline */ +.advancedSwitch :global(.mantine-Switch-input) { + outline: none !important; +} + +.advancedSwitch :global(.mantine-Switch-input:focus) { + outline: none !important; + box-shadow: none !important; +} + diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx b/frontend/src/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx new file mode 100644 index 000000000..37d35953c --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx @@ -0,0 +1,74 @@ +import { Button, Text, Group, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import classes from './BulkSelectionPanel.module.css'; +import { LogicalOperator } from './BulkSelection'; + +interface OperatorsSectionProps { + csvInput: string; + onInsertOperator: (op: LogicalOperator) => void; +} + +const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => { + const { t } = useTranslation(); + + return ( +
+ {t('bulkSelection.keywords.title', 'Keywords')}: + + + + + + + + + + +
+ ); +}; + +export default OperatorsSection; diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx b/frontend/src/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx new file mode 100644 index 000000000..46652d85a --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx @@ -0,0 +1,94 @@ +import { TextInput, Button, Text, Flex, Switch } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '../../shared/LocalIcon'; +import { Tooltip } from '../../shared/Tooltip'; +import { usePageSelectionTips } from '../../tooltips/usePageSelectionTips'; +import classes from './BulkSelectionPanel.module.css'; + +interface PageSelectionInputProps { + csvInput: string; + setCsvInput: (value: string) => void; + onUpdatePagesFromCSV: (override?: string) => void; + onClear: () => void; + advancedOpened?: boolean; + onToggleAdvanced?: (v: boolean) => void; +} + +const PageSelectionInput = ({ + csvInput, + setCsvInput, + onUpdatePagesFromCSV, + onClear, + advancedOpened, + onToggleAdvanced, +}: PageSelectionInputProps) => { + const { t } = useTranslation(); + const pageSelectionTips = usePageSelectionTips(); + + return ( +
+ {/* Header row with tooltip/title and advanced toggle */} + + + e.stopPropagation()} align="center" gap="xs"> + + Page Selection + + + {typeof advancedOpened === 'boolean' && ( + + {t('bulkSelection.advanced.title', 'Advanced')} + onToggleAdvanced?.(e.currentTarget.checked)} + title={t('bulkSelection.advanced.title', 'Advanced')} + className={classes.advancedSwitch} + /> + + )} + + + {/* Text input */} + { + const next = e.target.value; + setCsvInput(next); + onUpdatePagesFromCSV(next); + }} + placeholder="1,3,5-10" + rightSection={ + csvInput && ( + + ) + } + onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()} + className={classes.textInput} + /> +
+ ); +}; + +export default PageSelectionInput; diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/SelectPages.tsx b/frontend/src/components/pageEditor/bulkSelectionPanel/SelectPages.tsx new file mode 100644 index 000000000..c2787d05e --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/SelectPages.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Button, Text, NumberInput, Group } from '@mantine/core'; +import classes from './BulkSelectionPanel.module.css'; + +interface SelectPagesProps { + title: string; + placeholder: string; + onApply: (value: number) => void; + maxPages: number; + validationFn?: (value: number) => string | null; + isRange?: boolean; + rangeEndValue?: number | ''; + onRangeEndChange?: (value: string | number) => void; + rangeEndPlaceholder?: string; +} + +const SelectPages = ({ + title, + placeholder, + onApply, + validationFn, + isRange = false, + rangeEndValue, + onRangeEndChange, + rangeEndPlaceholder, +}: SelectPagesProps) => { + const [value, setValue] = useState(''); + const [error, setError] = useState(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 ( +
+ {title} + {error && ({error})} +
+ + {isRange ? ( + <> +
+ +
+
+ +
+ + ) : ( + + )} + +
+
+
+ ); +}; + +export default SelectPages; diff --git a/frontend/src/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx b/frontend/src/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx new file mode 100644 index 000000000..26fcfb8da --- /dev/null +++ b/frontend/src/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx @@ -0,0 +1,35 @@ +import { Text } from '@mantine/core'; +import classes from './BulkSelectionPanel.module.css'; + +interface SelectedPagesDisplayProps { + selectedPageIds: string[]; + displayDocument?: { pages: { id: string; pageNumber: number }[] }; + syntaxError: string | null; +} + +const SelectedPagesDisplay = ({ + selectedPageIds, + displayDocument, + syntaxError, +}: SelectedPagesDisplayProps) => { + if (selectedPageIds.length === 0 && !syntaxError) { + return null; + } + + return ( +
+ {syntaxError ? ( + {syntaxError} + ) : ( + + 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(', ') : ''}) + + )} +
+ ); +}; + +export default SelectedPagesDisplay; diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts index 96b93aa63..26cb9e09c 100644 --- a/frontend/src/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -59,6 +59,7 @@ export class DeletePagesCommand extends DOMCommand { private originalSelectedPages: number[] = []; private hasExecuted: boolean = false; private pageIdsToDelete: string[] = []; + private onAllPagesDeleted?: () => void; constructor( private pagesToDelete: number[], @@ -67,9 +68,11 @@ export class DeletePagesCommand extends DOMCommand { private setSelectedPages: (pages: number[]) => void, private getSplitPositions: () => Set, private setSplitPositions: (positions: Set) => void, - private getSelectedPages: () => number[] + private getSelectedPages: () => number[], + onAllPagesDeleted?: () => void ) { super(); + this.onAllPagesDeleted = onAllPagesDeleted; } execute(): void { @@ -99,7 +102,13 @@ export class DeletePagesCommand extends DOMCommand { !this.pageIdsToDelete.includes(page.id) ); - if (remainingPages.length === 0) return; // Safety check + if (remainingPages.length === 0) { + // If all pages would be deleted, clear selection/splits and close PDF + this.setSelectedPages([]); + this.setSplitPositions(new Set()); + this.onAllPagesDeleted?.(); + return; + } // Renumber remaining pages remainingPages.forEach((page, index) => { diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index ee0f9b911..462b29d7e 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -11,6 +11,7 @@ import LanguageSelector from '../shared/LanguageSelector'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { Tooltip } from '../shared/Tooltip'; import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel'; +import { parseSelection } from '../../utils/bulkselection/parseSelection'; export default function RightRail() { const { t } = useTranslation(); @@ -111,50 +112,13 @@ export default function RightRail() { setSelectedFiles([]); }, [currentView, selectedFileIds, removeFiles, setSelectedFiles]); - // CSV parsing functions for page selection - const parseCSVInput = useCallback((csv: string) => { - const pageNumbers: number[] = []; - const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); - - ranges.forEach(range => { - if (range.includes('-')) { - const [start, end] = range.split('-').map(n => parseInt(n.trim())); - for (let i = start; i <= end; i++) { - if (i > 0) { - pageNumbers.push(i); - } - } - } else { - const pageNum = parseInt(range); - if (pageNum > 0) { - pageNumbers.push(pageNum); - } - } - }); - - return pageNumbers; - }, []); - - const updatePagesFromCSV = useCallback(() => { - const rawPages = parseCSVInput(csvInput); - // Use PageEditor's total pages for validation + const updatePagesFromCSV = useCallback((override?: string) => { const maxPages = pageEditorFunctions?.totalPages || 0; - const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b); - // Use PageEditor's function to set selected pages + const normalized = parseSelection(override ?? csvInput, maxPages); pageEditorFunctions?.handleSetSelectedPages?.(normalized); - }, [csvInput, parseCSVInput, pageEditorFunctions]); + }, [csvInput, pageEditorFunctions]); - // Sync csvInput with PageEditor's selected pages - useEffect(() => { - const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument - ? pageEditorFunctions.selectedPageIds.map(id => { - const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id); - return page?.pageNumber || 0; - }).filter(num => num > 0).sort((a, b) => a - b) - : []; - const newCsvInput = sortedPageNumbers.join(', '); - setCsvInput(newCsvInput); - }, [pageEditorFunctions?.selectedPageIds]); + // Do not overwrite user's expression input when selection changes. // Clear CSV input when files change (use stable signature to avoid ref churn) useEffect(() => { @@ -260,7 +224,7 @@ export default function RightRail() { -
+
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 = ({ @@ -44,57 +44,41 @@ export const Tooltip: React.FC = ({ portalTarget, header, delay = 0, - containerStyle={}, + containerStyle = {}, + pinOnClick = false, + closeOnOutside = true, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); - const triggerRef = useRef(null); - const tooltipRef = useRef(null); + + const triggerRef = useRef(null); + const tooltipRef = useRef(null); const openTimeoutRef = useRef | null>(null); - - const clearTimers = () => { + const clickPendingRef = useRef(false); + const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); + + const clearTimers = useCallback(() => { if (openTimeoutRef.current) { clearTimeout(openTimeoutRef.current); openTimeoutRef.current = null; } - }; - - // Get sidebar context for tooltip positioning + }, []); + const sidebarContext = sidebarTooltip ? useSidebarContext() : null; - // Always use controlled mode - if no controlled props provided, use internal state const isControlled = controlledOpen !== undefined; - const open = isControlled ? controlledOpen : internalOpen; + const open = isControlled ? !!controlledOpen : internalOpen; - const handleOpenChange = (newOpen: boolean) => { - clearTimers(); - if (isControlled) { - onOpenChange?.(newOpen); - } else { - setInternalOpen(newOpen); - } + const setOpen = useCallback( + (newOpen: boolean) => { + if (newOpen === open) return; // avoid churn + if (isControlled) onOpenChange?.(newOpen); + else setInternalOpen(newOpen); + if (!newOpen) setIsPinned(false); + }, + [isControlled, onOpenChange, open] + ); - // Reset pin state when closing - if (!newOpen) { - setIsPinned(false); - } - - }; - - const handleTooltipClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsPinned(true); - }; - - const handleDocumentClick = (e: MouseEvent) => { - // If tooltip is pinned and we click outside of it, unpin it - if (isPinned && isClickOutside(e, tooltipRef.current)) { - setIsPinned(false); - handleOpenChange(false); - } - }; - - // Use the positioning hook const { coords, positionReady } = useTooltipPosition({ open, sidebarTooltip, @@ -103,56 +87,209 @@ export const Tooltip: React.FC = ({ triggerRef, tooltipRef, sidebarRefs: sidebarContext?.sidebarRefs, - sidebarState: sidebarContext?.sidebarState + sidebarState: sidebarContext?.sidebarState, }); - // Add document click listener for unpinning + // Close on outside click: pinned → close; not pinned → optionally close + const handleDocumentClick = useCallback( + (e: MouseEvent) => { + const tEl = tooltipRef.current; + const trg = triggerRef.current; + const target = e.target as Node | null; + const insideTooltip = tEl && target && tEl.contains(target); + const insideTrigger = trg && target && trg.contains(target); + + // If pinned: only close when clicking outside BOTH tooltip & trigger + if (isPinned) { + if (!insideTooltip && !insideTrigger) { + setIsPinned(false); + setOpen(false); + } + return; + } + + // Not pinned and configured to close on outside + if (closeOnOutside && !insideTooltip && !insideTrigger) { + setOpen(false); + } + }, + [isPinned, closeOnOutside, setOpen] + ); + useEffect(() => { - if (isPinned) { + // Attach global click when open (so hover tooltips can also close on outside if desired) + if (open || isPinned) { return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); } - }, [isPinned]); + }, [open, isPinned, handleDocumentClick]); - useEffect(() => { - return () => { - clearTimers(); - }; - }, []); + useEffect(() => () => clearTimers(), [clearTimers]); - - const getArrowClass = () => { - // No arrow for sidebar tooltips + const arrowClass = useMemo(() => { if (sidebarTooltip) return null; + const map: Record, string> = { + top: 'tooltip-arrow-bottom', + bottom: 'tooltip-arrow-top', + left: 'tooltip-arrow-left', + right: 'tooltip-arrow-right', + }; + return map[position] || map.right; + }, [position, sidebarTooltip]); - switch (position) { - case 'top': return "tooltip-arrow tooltip-arrow-bottom"; - case 'bottom': return "tooltip-arrow tooltip-arrow-top"; - case 'left': return "tooltip-arrow tooltip-arrow-left"; - case 'right': return "tooltip-arrow tooltip-arrow-right"; - default: return "tooltip-arrow tooltip-arrow-right"; - } - }; + const getArrowStyleClass = useCallback( + (key: string) => + styles[key as keyof typeof styles] || + styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] || + '', + [] + ); - 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] || - ''; - }; + // === Trigger handlers === + const openWithDelay = useCallback(() => { + clearTimers(); + openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0)); + }, [clearTimers, setOpen, delay]); + + const handlePointerEnter = useCallback( + (e: React.PointerEvent) => { + if (!isPinned) openWithDelay(); + (children.props as any)?.onPointerEnter?.(e); + }, + [isPinned, openWithDelay, children.props] + ); + + const handlePointerLeave = useCallback( + (e: React.PointerEvent) => { + const related = e.relatedTarget as Node | null; + + // Moving into the tooltip → keep open + if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + (children.props as any)?.onPointerLeave?.(e); + return; + } + + // Ignore transient leave between mousedown and click + if (clickPendingRef.current) { + (children.props as any)?.onPointerLeave?.(e); + return; + } + + clearTimers(); + if (!isPinned) setOpen(false); + (children.props as any)?.onPointerLeave?.(e); + }, + [clearTimers, isPinned, setOpen, children.props] + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + clickPendingRef.current = true; + (children.props as any)?.onMouseDown?.(e); + }, + [children.props] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + // allow microtask turn so click can see this false + queueMicrotask(() => (clickPendingRef.current = false)); + (children.props as any)?.onMouseUp?.(e); + }, + [children.props] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + clearTimers(); + if (pinOnClick) { + e.preventDefault?.(); + e.stopPropagation?.(); + if (!open) setOpen(true); + setIsPinned(true); + clickPendingRef.current = false; + return; + } + clickPendingRef.current = false; + (children.props as any)?.onClick?.(e); + }, + [clearTimers, pinOnClick, open, setOpen, children.props] + ); + + // Keyboard / focus accessibility + const handleFocus = useCallback( + (e: React.FocusEvent) => { + if (!isPinned) openWithDelay(); + (children.props as any)?.onFocus?.(e); + }, + [isPinned, openWithDelay, children.props] + ); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + const related = e.relatedTarget as Node | null; + if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + (children.props as any)?.onBlur?.(e); + return; + } + if (!isPinned) setOpen(false); + (children.props as any)?.onBlur?.(e); + }, + [isPinned, setOpen, children.props] + ); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }, [setOpen]); + + // Keep open while pointer is over the tooltip; close when leaving it (if not pinned) + const handleTooltipPointerEnter = useCallback(() => { + clearTimers(); + }, [clearTimers]); + + const handleTooltipPointerLeave = useCallback( + (e: React.PointerEvent) => { + const related = e.relatedTarget as Node | null; + if (related && triggerRef.current && triggerRef.current.contains(related)) return; + if (!isPinned) setOpen(false); + }, + [isPinned, setOpen] + ); + + // Enhance child with handlers and ref + const childWithHandlers = React.cloneElement(children as any, { + ref: (node: HTMLElement | null) => { + triggerRef.current = node || null; + const originalRef = (children as any).ref; + if (typeof originalRef === 'function') originalRef(node); + else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node; + }, + 'aria-describedby': open ? tooltipIdRef.current : undefined, + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onClick: handleClick, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + }); - // Always mount when open so we can measure; hide until positioned to avoid flash const shouldShowTooltip = open; const tooltipElement = shouldShowTooltip ? ( - } -> - - -``` - -### Mixed Content (Tips + Custom JSX) - -```tsx -Additional custom content below tips
} -> - - -``` - -### Sidebar Tooltips - -```tsx -// For items in a sidebar/navigation - -
- 📁 File Manager -
-
-``` - -### With Arrows - -```tsx - - + + ``` @@ -180,63 +158,55 @@ interface TooltipTip { ```tsx function ManualControlTooltip() { const [open, setOpen] = useState(false); - return ( - - + + ); } ``` -## Click-to-Pin Interaction +### Sidebar Tooltip -### How to Use (Default Behavior) -1. **Hover** over the trigger element to show the tooltip -2. **Click** the trigger element to pin the tooltip open -3. **Click** the red X button in the top-right corner to close -4. **Click** anywhere outside the tooltip to close -5. **Click** the trigger again to toggle pin state +```tsx + +
📁 File Manager
+
+``` -### Visual States -- **Unpinned**: Normal tooltip appearance -- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner +### Mixed Content -## Link Support +```tsx +Additional custom content below tips
} +> + + +``` -The tooltip fully supports clickable links in all content areas: +--- -- **Descriptions**: Use `` in description strings -- **Bullets**: Use `` in bullet point strings -- **Body**: Use JSX `` elements in the body ReactNode -- **Content**: Use JSX `` elements in custom content +## Positioning Notes -Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`. +* Initial placement is derived from `position` (or sidebar rules when `sidebarTooltip` is true). +* Tooltip is clamped within the viewport; the arrow is offset to remain visually aligned with the trigger. +* Sidebar mode positions to the sidebar’s edge and clamps vertically. Arrows are disabled in sidebar mode. -## Positioning Logic +--- -### Regular Tooltips -- Uses the `position` prop to determine initial placement -- Automatically clamps to viewport boundaries -- Calculates optimal position based on trigger element's `getBoundingClientRect()` -- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped +## Caveats & Tips -## Timing Details +* Ensure your container doesn’t block pointer events between trigger and tooltip. +* When using `portalTarget`, confirm it’s attached to `document.body` before rendering. +* For very dynamic layouts, call positioning after layout changes (the hook already listens to open/refs/viewport). -- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned). -- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps. -- Only one tooltip can be open at a time; hovering a new trigger closes others immediately. +--- -### Sidebar Tooltips -- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar -- Vertical positioning follows the trigger but clamps to viewport -- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position -- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed -- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`) -- **No arrows** - sidebar tooltips don't show arrows +## Changelog (since previous README) + +* Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`). +* Clarified outside‑click behavior for pinned vs unpinned. +* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`. +* Removed references to non‑existent props (e.g., `delayAppearance`). +* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`). diff --git a/frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx b/frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx new file mode 100644 index 000000000..ef95718e9 --- /dev/null +++ b/frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx @@ -0,0 +1,99 @@ +import { Stack, Divider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ChangeMetadataParameters, createCustomMetadataFunctions } from "../../../hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { useMetadataExtraction } from "../../../hooks/tools/changeMetadata/useMetadataExtraction"; +import DeleteAllStep from "./steps/DeleteAllStep"; +import StandardMetadataStep from "./steps/StandardMetadataStep"; +import DocumentDatesStep from "./steps/DocumentDatesStep"; +import AdvancedOptionsStep from "./steps/AdvancedOptionsStep"; + +interface ChangeMetadataSingleStepProps { + parameters: ChangeMetadataParameters; + onParameterChange: (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 ( + + {/* Delete All */} + + + {t('changeMetadata.deleteAll.label', 'Delete All Metadata')} + + + + + + + {/* Standard Metadata Fields */} + + + {t('changeMetadata.standardFields.title', 'Standard Metadata')} + + + + + + + {/* Document Dates */} + + + {t('changeMetadata.dates.title', 'Document Dates')} + + + + + + + {/* Advanced Options */} + + + {t('changeMetadata.advanced.title', 'Advanced Options')} + + + + + ); +}; + +export default ChangeMetadataSingleStep; diff --git a/frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx b/frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx new file mode 100644 index 000000000..0edffe21d --- /dev/null +++ b/frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx @@ -0,0 +1,60 @@ +import { Stack, Select, Divider } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { TrappedStatus } from "../../../../types/metadata"; +import CustomMetadataStep from "./CustomMetadataStep"; + +interface AdvancedOptionsStepProps { + parameters: ChangeMetadataParameters; + onParameterChange: (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 ( + + {/* Trapped Status */} +