From d2de8e54aacf7f85fff4e70c67d13cbb47b88a6c Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:19:52 +0100 Subject: [PATCH] change bulk selection panel to allow more versatile input (#4394) # Description of Changes - Add features to BulkSelectionPanel to allow more versatility when selecting pages - Make changes to Tooltip to: Remove non-existent props delayAppearance, fixed defaults no hardcoded maxWidth, and documented new props (closeOnOutside, containerStyle, minWidth). Clarify pinned vs. unpinned outside-click logic, hover/focus interactions, and event/ref preservation. - Made top controls show full text always rather than dynamically display the text only for the selected items --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 71 ++- .../public/locales/en-US/translation.json | 67 +++ .../pageEditor/BulkSelectionPanel.tsx | 83 ++-- .../src/components/pageEditor/PageEditor.tsx | 9 +- .../AdvancedSelectionPanel.tsx | 147 +++++++ .../bulkSelectionPanel/BulkSelection.ts | 136 ++++++ .../BulkSelectionPanel.module.css | 295 +++++++++++++ .../bulkSelectionPanel/OperatorsSection.tsx | 74 ++++ .../bulkSelectionPanel/PageSelectionInput.tsx | 94 ++++ .../bulkSelectionPanel/SelectPages.tsx | 104 +++++ .../SelectedPagesDisplay.tsx | 35 ++ .../pageEditor/commands/pageCommands.ts | 13 +- frontend/src/components/shared/RightRail.tsx | 48 +- frontend/src/components/shared/Tooltip.tsx | 376 ++++++++++------ .../src/components/shared/TopControls.tsx | 54 +-- .../shared/tooltip/Tooltip.README.md | 358 +++++++-------- .../src/components/tools/shared/ToolStep.tsx | 1 + .../tooltips/usePageSelectionTips.ts | 44 ++ frontend/src/styles/theme.css | 12 + frontend/src/utils/bulkselection/README.md | 77 ++++ .../bulkselection/parseSelection.test.ts | 253 +++++++++++ .../src/utils/bulkselection/parseSelection.ts | 413 ++++++++++++++++++ 22 files changed, 2323 insertions(+), 441 deletions(-) create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelection.ts create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/SelectPages.tsx create mode 100644 frontend/src/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx create mode 100644 frontend/src/components/tooltips/usePageSelectionTips.ts create mode 100644 frontend/src/utils/bulkselection/README.md create mode 100644 frontend/src/utils/bulkselection/parseSelection.test.ts create mode 100644 frontend/src/utils/bulkselection/parseSelection.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 017d01e06..9d53d472a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1194,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.", @@ -1213,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": { diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 359b27160..a58268664 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -838,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": { 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/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 203c3b5ab..4d9c5a187 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -48,6 +48,7 @@ const renderTooltipTitle = ( tips={tooltip.tips} header={tooltip.header} sidebarTooltip={true} + pinOnClick={true} > e.stopPropagation()}> diff --git a/frontend/src/components/tooltips/usePageSelectionTips.ts b/frontend/src/components/tooltips/usePageSelectionTips.ts new file mode 100644 index 000000000..70874968d --- /dev/null +++ b/frontend/src/components/tooltips/usePageSelectionTips.ts @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const usePageSelectionTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('bulkSelection.header.title', 'Page Selection Guide'), + }, + tips: [ + { + title: t('bulkSelection.syntax.title', 'Syntax Basics'), + description: t('bulkSelection.syntax.text', 'Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.'), + bullets: [ + t('bulkSelection.syntax.bullets.numbers', 'Numbers/ranges: 5, 10-20'), + t('bulkSelection.syntax.bullets.keywords', 'Keywords: odd, even'), + t('bulkSelection.syntax.bullets.progressions', 'Progressions: 3n, 4n+1'), + ] + }, + { + title: t('bulkSelection.operators.title', 'Operators'), + description: t('bulkSelection.operators.text', 'AND has higher precedence than comma. NOT applies within the document range.'), + bullets: [ + t('bulkSelection.operators.and', 'AND: & or "and" — require both conditions (e.g., 1-50 & even)'), + t('bulkSelection.operators.comma', 'Comma: , or | — combine selections (e.g., 1-10, 20)'), + t('bulkSelection.operators.not', 'NOT: ! or "not" — exclude pages (e.g., 3n & not 30)'), + ] + }, + { + title: t('bulkSelection.examples.title', 'Examples'), + bullets: [ + `${t('bulkSelection.examples.first50', 'First 50')}: 1-50`, + `${t('bulkSelection.examples.last50', 'Last 50')}: 451-500`, + `${t('bulkSelection.examples.every3rd', 'Every 3rd')}: 3n`, + `${t('bulkSelection.examples.oddWithinExcluding', 'Odd within 1-20 excluding 5-7')}: 1-20 & odd & !5-7`, + `${t('bulkSelection.examples.combineSets', 'Combine sets')}: 1-50, 451-500`, + ] + } + ] + }; +}; + + diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 6643ca580..4b91e7ed4 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -178,6 +178,12 @@ --checkbox-border: #2F83BF; --checkbox-checked-bg: #3FAFFF; --checkbox-tick: #FFFFFF; + + /* 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"] { @@ -322,6 +328,12 @@ --tool-panel-search-bg: #1F2329; --tool-panel-search-border-bottom: #4B525A; + /* 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 */ diff --git a/frontend/src/utils/bulkselection/README.md b/frontend/src/utils/bulkselection/README.md new file mode 100644 index 000000000..ea0e047a3 --- /dev/null +++ b/frontend/src/utils/bulkselection/README.md @@ -0,0 +1,77 @@ +## Bulk Selection Expressions + +### What this does + +- Lets you select pages using compact expressions instead of typing long CSV lists. +- Your input expression is preserved exactly as typed; we only expand it under the hood into concrete page numbers based on the current document's page count. +- The final selection is always deduplicated, clamped to valid page numbers, and sorted ascending. + +### Basic forms + +- Numbers: `5` selects page 5. +- Ranges: `3-7` selects pages 3,4,5,6,7 (inclusive). If the start is greater than the end, it is swapped automatically (e.g., `7-3` → `3-7`). +- Lists (OR): `1,3-5,10` selects pages 1,3,4,5,10. + +You can still use the original CSV format. For example, `1,2,3,4,5` (first five pages) continues to work. + +### Logical operators + +- OR (union): `,` or `|` or the word `or` +- AND (intersection): `&` or the word `and` +- NOT (complement within 1..max): `!term` or `!(group)` or the word `not term` / `not (group)` + +Operator precedence (from highest to lowest): +1) `!` (NOT) +2) `&` / `and` (AND) +3) `,` / `|` / `or` (OR) + +Use parentheses `(...)` to override precedence where needed. + +### Keywords and progressions + +- Keywords (case-insensitive): + - `even`: all even pages (2, 4, 6, ...) + - `odd`: all odd pages (1, 3, 5, ...) + +- Arithmetic progressions: `k n ± c`, e.g. `2n`, `3n+1`, `4n-1` + - `n` starts at 0 (CSS-style: `:nth-child`), then increases by 1 (n = 0,1,2,...). Non-positive results are discarded. + - `k` must be a positive integer (≥ 1). `c` can be any integer (including negative). + - Examples: + - `2n` → 0,2,4,6,... → becomes 2,4,6,... after discarding non-positive + - `2n-1` → -1,1,3,5,... → becomes 1,3,5,... (odd) + - `3n+1` → 1,4,7,10,13,... + +All selections are automatically limited to the current document's valid page numbers `[1..maxPages]`. + +### Parentheses + +- Group with parentheses to control evaluation order and combine NOT with groups. +- Examples: + - `1-10 & (even, 15)` → even pages 2,4,6,8,10 (15 is outside 1-10) + - `!(1-5, odd)` → remove pages 1..5 and all odd pages; for a 10-page doc this yields 6,8,10 + - `!(10-20 & !2n)` → complement of odd pages from 11..19 inside 10..20 + - `(2n | 3n+1) & 1-20` → union of even numbers and 3n+1 numbers, intersected with 1..20 + +### Whitespace and case + +- Whitespace is ignored: ` odd & 1 - 7` is valid. +- Keywords are case-insensitive: `ODD`, `Odd`, `odd` all work. + +### Universe, clamping, deduplication + +- The selection universe is the document's pages `[1..maxPages]`. +- Numbers outside the universe are discarded. +- Ranges are clamped to `[1..maxPages]` (e.g., `0-5` → `1-5`, `9-999` in a 10-page doc → `9-10`). +- Duplicates are removed; the final result is sorted ascending. + +### Examples + +- `1-10 & 2n & !5-7` → 2,4,8,10 +- `odd` → 1,3,5,7,9,... +- `even` → 2,4,6,8,10,... +- `2n-1` → 1,3,5,7,9,... +- `3n+1` → 4,7,10,13,16,... (up to max pages) +- `1-3, 8-9` → 1,2,3,8,9 +- `1-2 | 9-10 or 5` → 1,2,5,9,10 +- `!(1-5)` → remove the first five pages from the universe +- `!(10-20 & !2n)` → complement of odd pages between 10 and 20 diff --git a/frontend/src/utils/bulkselection/parseSelection.test.ts b/frontend/src/utils/bulkselection/parseSelection.test.ts new file mode 100644 index 000000000..de674844c --- /dev/null +++ b/frontend/src/utils/bulkselection/parseSelection.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { parseSelection } from './parseSelection'; + +describe('parseSelection', () => { + const max = 120; + + it('1) parses single numbers', () => { + expect(parseSelection('5', max)).toEqual([5]); + }); + + it('2) parses simple range', () => { + expect(parseSelection('3-7', max)).toEqual([3,4,5,6,7]); + }); + + it('3) parses multiple numbers and ranges via comma OR', () => { + expect(parseSelection('1,3-5,10', max)).toEqual([1,3,4,5,10]); + }); + + it('4) respects bounds (clamps to 1..max and filters invalid)', () => { + expect(parseSelection('0, -2, 1-2, 9999', max)).toEqual([1,2]); + }); + + it('5) supports even keyword', () => { + expect(parseSelection('even', 10)).toEqual([2,4,6,8,10]); + }); + + it('6) supports odd keyword', () => { + expect(parseSelection('odd', 10)).toEqual([1,3,5,7,9]); + }); + + it('7) supports 2n progression', () => { + expect(parseSelection('2n', 12)).toEqual([2,4,6,8,10,12]); + }); + + it('8) supports kn±c progression (3n+1)', () => { + expect(parseSelection('3n+1', 10)).toEqual([1,4,7,10]); + }); + + it('9) supports kn±c progression (4n-1)', () => { + expect(parseSelection('4n-1', 15)).toEqual([3,7,11,15]); + }); + + it('10) supports logical AND (&) intersection', () => { + // even AND 1-10 => even numbers within 1..10 + expect(parseSelection('even & 1-10', 20)).toEqual([2,4,6,8,10]); + }); + + it('11) supports logical OR with comma', () => { + expect(parseSelection('1-3, 8-9', 20)).toEqual([1,2,3,8,9]); + }); + + it('12) supports logical OR with | and word or', () => { + expect(parseSelection('1-2 | 9-10 or 5', 20)).toEqual([1,2,5,9,10]); + }); + + it('13) supports NOT operator !', () => { + // !1-5 within max=10 -> 6..10 + expect(parseSelection('!1-5', 10)).toEqual([6,7,8,9,10]); + }); + + it('14) supports combination: 1-10 & 2n & !5-7', () => { + expect(parseSelection('1-10 & 2n & !5-7', 20)).toEqual([2,4,8,10]); + }); + + it('15) preserves precedence: AND over OR', () => { + // 1-10 & even, 15 OR => ( (1-10 & even) , 15 ) + expect(parseSelection('1-10 & even, 15', 20)).toEqual([2,4,6,8,10,15]); + }); + + it('16) handles whitespace and case-insensitive keywords', () => { + expect(parseSelection(' OdD & 1-7 ', 10)).toEqual([1,3,5,7]); + }); + + it('17) progression plus range: 2n | 9-11 within 12', () => { + expect(parseSelection('2n | 9-11', 12)).toEqual([2,4,6,8,9,10,11,12]); + }); + + it('18) complex: (2n-1 & 1-20) & ! (5-7)', () => { + expect(parseSelection('2n-1 & 1-20 & !5-7', 20)).toEqual([1,3,9,11,13,15,17,19]); + }); + + it('19) falls back to CSV when expression malformed', () => { + // malformed: "2x" -> fallback should treat as CSV tokens -> only 2 ignored -> result empty + expect(parseSelection('2x', 10)).toEqual([]); + // malformed middle; still fallback handles CSV bits + expect(parseSelection('1, 3-5, foo, 9', 10)).toEqual([1,3,4,5,9]); + }); + + it('20) clamps ranges that exceed bounds', () => { + expect(parseSelection('0-5, 9-10', 10)).toEqual([1,2,3,4,5,9,10]); + }); + + it('21) supports parentheses to override precedence', () => { + // Without parentheses: 1-10 & even, 15 => [2,4,6,8,10,15] + // With parentheses around OR: 1-10 & (even, 15) => [2,4,6,8,10] + expect(parseSelection('1-10 & (even, 15)', 20)).toEqual([2,4,6,8,10]); + }); + + it('22) NOT over a grouped intersection', () => { + // !(10-20 & !2n) within 1..25 + // Inner: 10-20 & !2n => odd numbers from 11..19 plus 10,12,14,16,18,20 excluded + // Complement in 1..25 removes those, keeping others + const result = parseSelection('!(10-20 & !2n)', 25); + expect(result).toEqual([1,2,3,4,5,6,7,8,9,10,12,14,16,18,20,21,22,23,24,25]); + }); + + it('23) nested parentheses with progressions', () => { + expect(parseSelection('(2n | 3n+1) & 1-20', 50)).toEqual([ + 1,2,4,6,7,8,10,12,13,14,16,18,19,20 + ]); + }); + + it('24) parentheses with NOT directly on group', () => { + expect(parseSelection('!(1-5, odd)', 10)).toEqual([6,8,10]); + }); + + it('25) whitespace within parentheses is ignored', () => { + expect(parseSelection('( 1 - 3 , 6 )', 10)).toEqual([1,2,3,6]); + }); + + it('26) malformed missing closing parenthesis falls back to CSV', () => { + // Expression parse should fail; fallback CSV should pick numbers only + expect(parseSelection('(1-3, 6', 10)).toEqual([6]); + }); + + it('27) nested NOT and AND with parentheses', () => { + // !(odd & 5-9) within 1..12 => remove odd numbers 5,7,9 + expect(parseSelection('!(odd & 5-9)', 12)).toEqual([1,2,3,4,6,8,10,11,12]); + }); + + it('28) deep nesting and mixing operators', () => { + const expr = '(1-4 & 2n) , ( (5-10 & odd) & !(7) ), (3n+1 & 1-20)'; + expect(parseSelection(expr, 20)).toEqual([1,2,4,5,7,9,10,13,16,19]); + }); + + it('31) word NOT works like ! for terms', () => { + expect(parseSelection('not 1-3', 6)).toEqual([4,5,6]); + }); + + it('32) word NOT works like ! for groups', () => { + expect(parseSelection('not (odd & 1-6)', 8)).toEqual([2,4,6,7,8]); + }); + + it('29) parentheses around a single term has no effect', () => { + expect(parseSelection('(even)', 8)).toEqual([2,4,6,8]); + }); + + it('30) redundant nested parentheses', () => { + expect(parseSelection('(((1-3))), ((2n))', 6)).toEqual([1,2,3,4,6]); + }); + + // Additional edge cases and comprehensive coverage + it('33) handles empty input gracefully', () => { + expect(parseSelection('', 10)).toEqual([]); + expect(parseSelection(' ', 10)).toEqual([]); + }); + + it('34) handles zero or negative maxPages', () => { + expect(parseSelection('1-10', 0)).toEqual([]); + expect(parseSelection('1-10', -5)).toEqual([]); + }); + + it('35) handles large progressions efficiently', () => { + expect(parseSelection('100n', 1000)).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]); + }); + + it('36) handles progressions with large offsets', () => { + expect(parseSelection('5n+97', 100)).toEqual([97]); + expect(parseSelection('3n-2', 10)).toEqual([1, 4, 7, 10]); + }); + + it('37) mixed case keywords work correctly', () => { + expect(parseSelection('EVEN & Odd', 6)).toEqual([]); + expect(parseSelection('Even OR odd', 6)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('38) complex nested expressions with all operators', () => { + const expr = '(1-20 & even) | (odd & !5-15) | (3n+1 & 1-10)'; + // (1-20 & even) = [2,4,6,8,10,12,14,16,18,20] + // (odd & !5-15) = odd numbers not in 5-15 = [1,3,17,19] + // (3n+1 & 1-10) = [1,4,7,10] + // Union of all = [1,2,3,4,6,7,8,10,12,14,16,17,18,19,20] + expect(parseSelection(expr, 20)).toEqual([1, 2, 3, 4, 6, 7, 8, 10, 12, 14, 16, 17, 18, 19, 20]); + }); + + it('39) multiple NOT operators in sequence', () => { + expect(parseSelection('not not 1-5', 10)).toEqual([1, 2, 3, 4, 5]); + expect(parseSelection('!!!1-3', 10)).toEqual([4, 5, 6, 7, 8, 9, 10]); + }); + + it('40) edge case: single page selection', () => { + expect(parseSelection('1', 1)).toEqual([1]); + expect(parseSelection('5', 3)).toEqual([]); + }); + + it('41) backwards ranges are handled correctly', () => { + expect(parseSelection('10-5', 15)).toEqual([5, 6, 7, 8, 9, 10]); + }); + + it('42) progressions that start beyond maxPages', () => { + expect(parseSelection('10n+50', 40)).toEqual([]); + expect(parseSelection('5n+35', 40)).toEqual([35, 40]); + }); + + it('43) complex operator precedence with mixed syntax', () => { + // AND has higher precedence than OR + expect(parseSelection('1-3, 5-7 & even', 10)).toEqual([1, 2, 3, 6]); + expect(parseSelection('1-3 | 5-7 and even', 10)).toEqual([1, 2, 3, 6]); + }); + + it('44) whitespace tolerance in complex expressions', () => { + const expr1 = '1-5&even|odd&!3'; + const expr2 = ' 1 - 5 & even | odd & ! 3 '; + expect(parseSelection(expr1, 10)).toEqual(parseSelection(expr2, 10)); + }); + + it('45) fallback behavior with partial valid expressions', () => { + // Should fallback and extract valid CSV parts + expect(parseSelection('1, 2-4, invalid, 7', 10)).toEqual([1, 2, 3, 4, 7]); + expect(parseSelection('1-3, @#$, 8-9', 10)).toEqual([1, 2, 3, 8, 9]); + }); + + it('46) progressions with k=1 (equivalent to n)', () => { + expect(parseSelection('1n', 5)).toEqual([1, 2, 3, 4, 5]); + expect(parseSelection('1n+2', 5)).toEqual([2, 3, 4, 5]); + }); + + it('47) very large ranges are clamped correctly', () => { + expect(parseSelection('1-999999', 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + // Note: -100-5 would fallback to CSV and reject -100, but 0-5 should work + expect(parseSelection('0-5', 10)).toEqual([1, 2, 3, 4, 5]); + }); + + it('48) multiple comma-separated ranges', () => { + expect(parseSelection('1-2, 4-5, 7-8, 10', 10)).toEqual([1, 2, 4, 5, 7, 8, 10]); + }); + + it('49) combination of all features in one expression', () => { + const expr = '(1-10 & even) | (odd & 15-25) & !(3n+1 & 1-30) | 50n'; + const result = parseSelection(expr, 100); + // This should combine: even numbers 2,4,6,8,10 with odd 15-25 excluding 3n+1 matches, plus 50n + expect(result.length).toBeGreaterThan(0); + expect(result).toContain(50); + expect(result).toContain(100); + }); + + it('50) stress test with deeply nested parentheses', () => { + const expr = '((((1-5)))) & ((((even)))) | ((((odd & 7-9))))'; + expect(parseSelection(expr, 10)).toEqual([2, 4, 7, 9]); + }); +}); + + diff --git a/frontend/src/utils/bulkselection/parseSelection.ts b/frontend/src/utils/bulkselection/parseSelection.ts new file mode 100644 index 000000000..c83327483 --- /dev/null +++ b/frontend/src/utils/bulkselection/parseSelection.ts @@ -0,0 +1,413 @@ +// A parser that converts selection expressions (e.g., "1-10 & 2n & !50-100", "odd", "2n-1") +// into a list of page numbers within [1, maxPages]. + +/* + Supported grammar (case-insensitive for words): + expression := disjunction + disjunction := conjunction ( ("," | "|" | "or") conjunction )* + conjunction := unary ( ("&" | "and") unary )* + unary := ("!" unary) | ("not" unary) | primary + primary := "(" expression ")" | range | progression | keyword | number + range := number "-" number // inclusive + progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0 + keyword := "even" | "odd" + number := digits (>= 1) + + Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR) + Associativity: left-to-right within the same precedence level + + Notes: + - Whitespace is ignored. + - The universe is [1..maxPages]. The complement operator ("!" / "not") applies within this universe. + - Out-of-bounds numbers are clamped in ranges and ignored as singletons. + - On parse failure, the parser falls back to CSV (numbers and ranges separated by commas). + + Examples: + 1-10 & even -> even pages between 1 and 10 + !(5-7) -> all pages except 5..7 + 3n+1 -> 1,4,7,... (n starts at 0) + (2n | 3n+1) & 1-20 -> multiples of 2 or numbers of the form 3n+1 within 1..20 +*/ + +export function parseSelection(input: string, maxPages: number): number[] { + const clampedMax = Math.max(0, Math.floor(maxPages || 0)); + if (clampedMax === 0) return []; + + const trimmed = (input || '').trim(); + if (trimmed.length === 0) return []; + + try { + const parser = new ExpressionParser(trimmed, clampedMax); + const resultSet = parser.parse(); + return toSortedArray(resultSet); + } catch { + // Fallback: simple CSV parser (e.g., "1,3,5-10") + return toSortedArray(parseCsvFallback(trimmed, clampedMax)); + } +} + +export function parseSelectionWithDiagnostics( + input: string, + maxPages: number, + options?: { strict?: boolean } +): { pages: number[]; warning?: string } { + const clampedMax = Math.max(0, Math.floor(maxPages || 0)); + if (clampedMax === 0) return { pages: [] }; + + const trimmed = (input || '').trim(); + if (trimmed.length === 0) return { pages: [] }; + + try { + const parser = new ExpressionParser(trimmed, clampedMax); + const resultSet = parser.parse(); + return { pages: toSortedArray(resultSet) }; + } catch (err) { + if (options?.strict) { + throw err; + } + const pages = toSortedArray(parseCsvFallback(trimmed, clampedMax)); + const tokens = trimmed.split(',').map(t => t.trim()).filter(Boolean); + const bad = tokens.find(tok => !/^(\d+\s*-\s*\d+|\d+)$/.test(tok)); + const warning = `Malformed expression${bad ? ` at: '${bad}'` : ''}. Falling back to CSV interpretation.`; + return { pages, warning }; + } +} + +function toSortedArray(set: Set): number[] { + return Array.from(set).sort((a, b) => a - b); +} + +function parseCsvFallback(input: string, max: number): Set { + const result = new Set(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const set = new Set(); + if (n >= 1 && n <= this.max) set.add(n); + return set; + } + + private buildRange(a: number, b: number): Set { + const set = new Set(); + 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 { + const set = new Set(); + 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 { + return this.buildProgression(2, 0); + } + + private buildOdd(): Set { + 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, b: Set): Set { + if (a.size === 0) return new Set(b); + if (b.size === 0) return new Set(a); + const out = new Set(a); + for (const v of b) out.add(v); + return out; +} + +function intersect(a: Set, b: Set): Set { + if (a.size === 0 || b.size === 0) return new Set(); + const out = new Set(); + 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, max: number): Set { + const out = new Set(); + for (let i = 1; i <= max; i++) if (!a.has(i)) out.add(i); + return out; +} + +