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.
This commit is contained in:
EthanHealy01
2025-09-18 10:19:52 +00:00
committed by GitHub
parent 06e5205302
commit d2de8e54aa
22 changed files with 2323 additions and 441 deletions
@@ -1,12 +1,16 @@
import React from 'react';
import { Group, TextInput, Button, Text } from '@mantine/core';
import { useState, useEffect } from 'react';
import classes from './bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '../../utils/bulkselection/parseSelection';
import PageSelectionInput from './bulkSelectionPanel/PageSelectionInput';
import SelectedPagesDisplay from './bulkSelectionPanel/SelectedPagesDisplay';
import AdvancedSelectionPanel from './bulkSelectionPanel/AdvancedSelectionPanel';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: () => void;
onUpdatePagesFromCSV: (override?: string) => void;
}
const BulkSelectionPanel = ({
@@ -16,31 +20,56 @@ const BulkSelectionPanel = ({
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const maxPages = displayDocument?.pages?.length ?? 0;
// Validate input syntax and show lightweight feedback
useEffect(() => {
const text = (csvInput || '').trim();
if (!text) {
setSyntaxError(null);
return;
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
} catch {
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
}
}, [csvInput, maxPages]);
const handleClear = () => {
setCsvInput('');
onUpdatePagesFromCSV('');
};
return (
<>
<Group>
<TextInput
value={csvInput}
onChange={(e) => setCsvInput(e.target.value)}
placeholder="1,3,5-10"
label="Page Selection"
onBlur={onUpdatePagesFromCSV}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
style={{ flex: 1 }}
/>
<Button onClick={onUpdatePagesFromCSV} mt="xl">
Apply
</Button>
</Group>
{selectedPageIds.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
)}
</>
<div className={classes.panelContainer}>
<PageSelectionInput
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
onClear={handleClear}
advancedOpened={advancedOpened}
onToggleAdvanced={setAdvancedOpened}
/>
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={syntaxError}
/>
<AdvancedSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
maxPages={maxPages}
advancedOpened={advancedOpened}
/>
</div>
);
};
@@ -171,7 +171,8 @@ const PageEditor = ({
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds)
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}
@@ -228,7 +229,8 @@ const PageEditor = ({
},
() => splitPositions,
setSplitPositions,
() => selectedPageNumbers
() => selectedPageNumbers,
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
@@ -246,7 +248,8 @@ const PageEditor = ({
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds)
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from './BulkSelectionPanel.module.css';
import {
appendExpression,
insertOperatorSmart,
firstNExpression,
lastNExpression,
everyNthExpression,
rangeExpression,
LogicalOperator,
} from './BulkSelection';
import SelectPages from './SelectPages';
import OperatorsSection from './OperatorsSection';
interface AdvancedSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
onUpdatePagesFromCSV: (override?: string) => void;
maxPages: number;
advancedOpened?: boolean;
}
const AdvancedSelectionPanel = ({
csvInput,
setCsvInput,
onUpdatePagesFromCSV,
maxPages,
advancedOpened,
}: AdvancedSelectionPanelProps) => {
const { t } = useTranslation();
const [rangeEnd, setRangeEnd] = useState<number | ''>('');
const handleRangeEndChange = (val: string | number) => {
const next = typeof val === 'number' ? val : '';
setRangeEnd(next);
};
// Named validation functions
const validatePositiveNumber = (value: number): string | null => {
return value <= 0 ? 'Enter a positive number' : null;
};
const validateRangeStart = (start: number): string | null => {
if (start <= 0) return 'Values must be positive';
if (typeof rangeEnd === 'number' && start > rangeEnd) {
return 'From must be less than or equal to To';
}
return null;
};
// Named callback functions
const applyExpression = (expr: string) => {
const nextInput = appendExpression(csvInput, expr);
setCsvInput(nextInput);
onUpdatePagesFromCSV(nextInput);
};
const insertOperator = (op: LogicalOperator) => {
const next = insertOperatorSmart(csvInput, op);
setCsvInput(next);
// Trigger visual selection update for 'even' and 'odd' operators
if (op === 'even' || op === 'odd') {
onUpdatePagesFromCSV(next);
}
};
const handleFirstNApply = (value: number) => {
const expr = firstNExpression(value, maxPages);
if (expr) applyExpression(expr);
};
const handleLastNApply = (value: number) => {
const expr = lastNExpression(value, maxPages);
if (expr) applyExpression(expr);
};
const handleEveryNthApply = (value: number) => {
const expr = everyNthExpression(value);
if (expr) applyExpression(expr);
};
const handleRangeApply = (start: number) => {
if (typeof rangeEnd !== 'number') return;
const expr = rangeExpression(start, rangeEnd, maxPages);
if (expr) applyExpression(expr);
setRangeEnd('');
};
return (
<>
{/* Advanced section */}
{advancedOpened && (
<div className={classes.advancedSection}>
<div className={classes.advancedContent}>
{/* Cards row */}
<Flex direction="row" mb="xs" wrap="wrap">
<SelectPages
title={t('bulkSelection.firstNPages.title', 'First N Pages')}
placeholder={t('bulkSelection.firstNPages.placeholder', 'Number of pages')}
onApply={handleFirstNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
/>
<SelectPages
title={t('bulkSelection.range.title', 'Range')}
placeholder={t('bulkSelection.range.fromPlaceholder', 'From')}
onApply={handleRangeApply}
maxPages={maxPages}
validationFn={validateRangeStart}
isRange={true}
rangeEndValue={rangeEnd}
onRangeEndChange={handleRangeEndChange}
rangeEndPlaceholder={t('bulkSelection.range.toPlaceholder', 'To')}
/>
<SelectPages
title={t('bulkSelection.lastNPages.title', 'Last N Pages')}
placeholder={t('bulkSelection.lastNPages.placeholder', 'Number of pages')}
onApply={handleLastNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
/>
<SelectPages
title={t('bulkSelection.everyNthPage.title', 'Every Nth Page')}
placeholder={t('bulkSelection.everyNthPage.placeholder', 'Step size')}
onApply={handleEveryNthApply}
maxPages={maxPages}
/>
</Flex>
{/* Operators row at bottom */}
<OperatorsSection
csvInput={csvInput}
onInsertOperator={insertOperator}
/>
</div>
</div>
)}
</>
);
};
export default AdvancedSelectionPanel;
@@ -0,0 +1,136 @@
// Pure helper utilities for the BulkSelectionPanel UI
export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';
// Returns a new CSV expression with expr appended.
// If current ends with an operator token, expr is appended directly.
// Otherwise, it is joined with " or ".
export function appendExpression(currentInput: string, expr: string): string {
const current = (currentInput || '').trim();
if (!current) return expr;
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
// Add space if operator doesn't already have one
if (endsWithOperator) {
const needsSpace = !current.endsWith(' ');
return `${current}${needsSpace ? ' ' : ''}${expr}`;
}
return `${current} or ${expr}`;
}
// Smartly inserts/normalizes a logical operator at the end of the current input.
// Produces a trailing space to allow the next token to be typed naturally.
export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string {
const text = (currentInput || '').trim();
// Handle 'even' and 'odd' as page selection expressions, not logical operators
if (op === 'even' || op === 'odd') {
if (text.length === 0) return `${op} `;
// If current input ends with a logical operator, append the page selection with proper spacing
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
if (endsWithOperator) {
// Add space if the operator doesn't already have one
const needsSpace = !text.endsWith(' ');
return `${text}${needsSpace ? ' ' : ''}${op} `;
}
return `${text} or ${op} `;
}
if (text.length === 0) return `${op} `;
// Extract up to the last two operator tokens (words or symbols) from the end
const tokens: string[] = [];
let rest = text;
for (let i = 0; i < 2; i++) {
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
if (!m || m.index === undefined) break;
const raw = m[1].toLowerCase();
const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw;
tokens.unshift(word);
rest = rest.slice(0, m.index).trimEnd();
}
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
const click = op; // desired operator
if (tokens.length === 0) {
return emit(text, click);
}
// Normalize to allowed set
const phrase = tokens.join(' ');
const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']);
// Helpers for transitions from a single trailing token
const fromSingle = (t: string): string => {
if (t === 'and') {
if (click === 'and') return 'and';
if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or'
return 'and not';
}
if (t === 'or') {
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'or not';
}
// t === 'not'
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'not';
};
// From combined phrase
const fromCombo = (p: string): string => {
if (p === 'and not') {
if (click === 'not') return 'and not';
if (click === 'and') return 'and';
if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or'
return 'and not';
}
if (p === 'or not') {
if (click === 'not') return 'or not';
if (click === 'or') return 'or';
if (click === 'and') return 'and'; // 'or not and' is invalid, so just use 'and'
return 'or not';
}
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
return click;
};
const base = rest.trim();
const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
if (!allowed.has(nextPhrase)) {
return emit(base, click);
}
return emit(base, nextPhrase);
}
// Expression builders for Advanced actions
export function firstNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
return `1-${end}`;
}
export function lastNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const count = Math.max(1, Math.floor(n));
const start = Math.max(1, maxPages - count + 1);
if (maxPages <= 0) return null;
return `${start}-${maxPages}`;
}
export function everyNthExpression(n: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
return `${Math.max(1, Math.floor(n))}n`;
}
export function rangeExpression(start: number, end: number, maxPages: number): string | null {
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
let s = Math.floor(start);
let e = Math.floor(end);
if (s > e) [s, e] = [e, s];
s = Math.max(1, s);
e = maxPages > 0 ? Math.min(maxPages, e) : e;
return `${s}-${e}`;
}
@@ -0,0 +1,295 @@
.panelGroup {
max-width: 100%;
flex-wrap: wrap;
min-width: 24rem;
}
.textInput {
flex: 1;
max-width: 100%;
}
.dropdownContainer {
margin-top: 0.5rem;
}
.menuDropdown {
min-width: 22.5rem;
}
.dropdownContent {
display: flex;
gap: 0.75rem;
}
.leftCol {
flex: 1 1 auto;
min-width: 0;
max-width: calc(100% - 8rem - 0.75rem);
overflow: hidden;
}
.rightCol {
width: 8rem;
border-left: 0.0625rem solid var(--border-default);
padding-left: 0.75rem;
display: flex;
flex-direction: column;
}
.operatorGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.operatorChip {
width: 100%;
border-radius: 1.25rem;
border: 0.0625rem solid var(--bulk-card-border);
background-color: var(--bulk-card-bg);
color: var(--text-primary);
transition: all 0.2s ease;
min-height: 2rem;
}
.operatorChip:hover:not(:disabled) {
border-color: var(--bulk-card-hover-border);
background-color: var(--hover-bg);
transform: translateY(-0.0625rem);
box-shadow: var(--shadow-sm);
}
.operatorChip:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.operatorChip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
:global([data-mantine-color-scheme='dark']) .operatorChip {
background-color: var(--bulk-card-bg);
border-color: var(--bulk-card-border);
color: var(--text-primary);
}
:global([data-mantine-color-scheme='dark']) .operatorChip:hover:not(:disabled) {
background-color: var(--hover-bg);
border-color: var(--bulk-card-hover-border);
color: var(--text-primary);
}
.dropdownHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--border-default);
margin-bottom: 0.5rem;
}
.closeButton {
min-width: 1.5rem;
height: 1.5rem;
padding: 0;
font-size: 1.25rem;
font-weight: bold;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.menuItemRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.chevron {
color: var(--text-muted);
}
/* Icon-based chevrons */
.chevronIcon {
transition: transform 150ms ease;
display: inline-flex;
align-items: center;
}
.chevronDown {
transform: rotate(90deg);
}
.chevronUp {
transform: rotate(270deg);
}
.inlineRow {
padding: 0.75rem 0.5rem;
}
.inlineRowCompact {
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
}
.menuItemCloseHover {
background-color: var(--text-brand-accent);
opacity: 0.1;
transition: background-color 150ms ease;
}
:global([data-mantine-color-scheme='dark']) .menuItemCloseHover {
background-color: var(--text-brand-accent);
opacity: 0.2;
}
.selectedList {
max-height: 8rem;
overflow: auto;
background-color: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
min-width: 24rem;
color: var(--text-primary);
}
.selectedText {
word-break: break-word;
max-width: 100%;
}
.advancedSection {
margin-top: 0.5rem;
min-width: 24rem;
}
.advancedHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--border-default);
margin-bottom: 0.5rem;
}
.advancedContent {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.advancedItem {
padding: 0.5rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 150ms ease;
}
.advancedItem:hover {
background-color: var(--hover-bg);
}
:global([data-mantine-color-scheme='dark']) .advancedItem:hover {
background-color: var(--hover-bg);
}
.advancedCard {
background-color: var(--bulk-card-bg);
border: none;
border-radius: 0.75rem;
padding: 0.25rem;
margin-bottom: 0.5rem;
width: 100%;
box-sizing: border-box;
color: var(--text-primary);
}
:global([data-mantine-color-scheme='dark']) .advancedCard {
background-color: var(--bulk-card-bg);
}
.inputGroup {
width: 100%;
}
.fullWidthInput {
flex: 1;
}
.applyButton {
min-width: 4rem;
flex-shrink: 0;
}
/* Style inputs and buttons within advanced cards to match bg-raised */
.advancedCard :global(.mantine-NumberInput-input) {
background-color: var(--bg-raised) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
.advancedCard :global(.mantine-Button-root) {
background-color: var(--bg-raised) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
.advancedCard :global(.mantine-Button-root:hover) {
background-color: var(--hover-bg) !important;
border-color: var(--border-strong) !important;
}
/* Error helper text above the input */
.errorText {
margin-top: 0.25rem;
color: var(--text-brand-accent);
}
/* Dark-mode adjustments */
:global([data-mantine-color-scheme='dark']) .selectedList {
background-color: var(--bg-raised);
}
/* Small screens: allow the section to shrink instead of enforcing a large min width */
@media (max-width: 480px) {
.panelGroup,
.selectedList,
.advancedSection,
.panelContainer {
min-width: 0;
}
}
/* Outermost panel container scrolling */
.panelContainer {
max-height: 95vh;
overflow: auto;
background-color: var(--bulk-panel-bg);
color: var(--text-primary);
border-radius: 0.5rem;
}
/* Override Mantine Popover dropdown background */
:global(.mantine-Popover-dropdown) {
background-color: var(--bulk-panel-bg) !important;
border-color: var(--bulk-card-border) !important;
color: var(--text-primary) !important;
}
/* Override Mantine Switch outline */
.advancedSwitch :global(.mantine-Switch-input) {
outline: none !important;
}
.advancedSwitch :global(.mantine-Switch-input:focus) {
outline: none !important;
box-shadow: none !important;
}
@@ -0,0 +1,74 @@
import { Button, Text, Group, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from './BulkSelectionPanel.module.css';
import { LogicalOperator } from './BulkSelection';
interface OperatorsSectionProps {
csvInput: string;
onInsertOperator: (op: LogicalOperator) => void;
}
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
const { t } = useTranslation();
return (
<div>
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">{t('bulkSelection.keywords.title', 'Keywords')}:</Text>
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('and')}
disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)"
>
<Text size="xs" fw={500}>and</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('or')}
disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)"
>
<Text size="xs" fw={500}>or</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('not')}
disabled={!csvInput.trim()}
title="Exclude from selection"
>
<Text size="xs" fw={500}>not</Text>
</Button>
</Group>
<Divider my="sm" />
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('even')}
title="Select all even-numbered pages (2, 4, 6, 8...)"
>
<Text size="xs" fw={500}>even</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('odd')}
title="Select all odd-numbered pages (1, 3, 5, 7...)"
>
<Text size="xs" fw={500}>odd</Text>
</Button>
</Group>
</div>
);
};
export default OperatorsSection;
@@ -0,0 +1,94 @@
import { TextInput, Button, Text, Flex, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip';
import { usePageSelectionTips } from '../../tooltips/usePageSelectionTips';
import classes from './BulkSelectionPanel.module.css';
interface PageSelectionInputProps {
csvInput: string;
setCsvInput: (value: string) => void;
onUpdatePagesFromCSV: (override?: string) => void;
onClear: () => void;
advancedOpened?: boolean;
onToggleAdvanced?: (v: boolean) => void;
}
const PageSelectionInput = ({
csvInput,
setCsvInput,
onUpdatePagesFromCSV,
onClear,
advancedOpened,
onToggleAdvanced,
}: PageSelectionInputProps) => {
const { t } = useTranslation();
const pageSelectionTips = usePageSelectionTips();
return (
<div className={classes.panelGroup}>
{/* Header row with tooltip/title and advanced toggle */}
<Flex justify="space-between" align="center" mb="sm">
<Tooltip
position="left"
offset={20}
header={pageSelectionTips.header}
portalTarget={document.body}
pinOnClick={true}
containerStyle={{ marginTop: "1rem"}}
tips={pageSelectionTips.tips}
>
<Flex onClick={(e) => e.stopPropagation()} align="center" gap="xs">
<LocalIcon icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" style={{ color: 'var(--text-instruction)' }} />
<Text>Page Selection</Text>
</Flex>
</Tooltip>
{typeof advancedOpened === 'boolean' && (
<Flex align="center" gap="xs">
<Text size="sm" c="var(--text-secondary)">{t('bulkSelection.advanced.title', 'Advanced')}</Text>
<Switch
size="sm"
checked={!!advancedOpened}
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
title={t('bulkSelection.advanced.title', 'Advanced')}
className={classes.advancedSwitch}
/>
</Flex>
)}
</Flex>
{/* Text input */}
<TextInput
value={csvInput}
onChange={(e) => {
const next = e.target.value;
setCsvInput(next);
onUpdatePagesFromCSV(next);
}}
placeholder="1,3,5-10"
rightSection={
csvInput && (
<Button
variant="subtle"
size="xs"
onClick={onClear}
style={{
color: 'var(--text-muted)',
minWidth: 'auto',
width: '24px',
height: '24px',
padding: 0
}}
>
×
</Button>
)
}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
className={classes.textInput}
/>
</div>
);
};
export default PageSelectionInput;
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Button, Text, NumberInput, Group } from '@mantine/core';
import classes from './BulkSelectionPanel.module.css';
interface SelectPagesProps {
title: string;
placeholder: string;
onApply: (value: number) => void;
maxPages: number;
validationFn?: (value: number) => string | null;
isRange?: boolean;
rangeEndValue?: number | '';
onRangeEndChange?: (value: string | number) => void;
rangeEndPlaceholder?: string;
}
const SelectPages = ({
title,
placeholder,
onApply,
validationFn,
isRange = false,
rangeEndValue,
onRangeEndChange,
rangeEndPlaceholder,
}: SelectPagesProps) => {
const [value, setValue] = useState<number | ''>('');
const [error, setError] = useState<string | null>(null);
const handleValueChange = (val: string | number) => {
const next = typeof val === 'number' ? val : '';
setValue(next);
if (validationFn && typeof next === 'number') {
setError(validationFn(next));
} else {
setError(null);
}
};
const handleApply = () => {
if (value === '' || typeof value !== 'number') return;
onApply(value);
setValue('');
setError(null);
};
const isDisabled = Boolean(error) || value === '';
return (
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">{title}</Text>
{error && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{error}</Text>)}
<div className={classes.inputGroup}>
<Group gap="sm" align="flex-end" wrap="nowrap">
{isRange ? (
<>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
error={Boolean(error)}
/>
</div>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={rangeEndValue}
onChange={onRangeEndChange}
min={1}
placeholder={rangeEndPlaceholder}
error={Boolean(error)}
/>
</div>
</>
) : (
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
className={classes.fullWidthInput}
error={Boolean(error)}
/>
)}
<Button
size="sm"
className={classes.applyButton}
onClick={handleApply}
disabled={isDisabled}
>
Apply
</Button>
</Group>
</div>
</div>
);
};
export default SelectPages;
@@ -0,0 +1,35 @@
import { Text } from '@mantine/core';
import classes from './BulkSelectionPanel.module.css';
interface SelectedPagesDisplayProps {
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
syntaxError: string | null;
}
const SelectedPagesDisplay = ({
selectedPageIds,
displayDocument,
syntaxError,
}: SelectedPagesDisplayProps) => {
if (selectedPageIds.length === 0 && !syntaxError) {
return null;
}
return (
<div className={classes.selectedList}>
{syntaxError ? (
<Text size="xs" className={classes.errorText}>{syntaxError}</Text>
) : (
<Text size="sm" c="dimmed" className={classes.selectedText}>
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
)}
</div>
);
};
export default SelectedPagesDisplay;
@@ -59,6 +59,7 @@ export class DeletePagesCommand extends DOMCommand {
private originalSelectedPages: number[] = [];
private hasExecuted: boolean = false;
private pageIdsToDelete: string[] = [];
private onAllPagesDeleted?: () => void;
constructor(
private pagesToDelete: number[],
@@ -67,9 +68,11 @@ export class DeletePagesCommand extends DOMCommand {
private setSelectedPages: (pages: number[]) => void,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[]
private getSelectedPages: () => number[],
onAllPagesDeleted?: () => void
) {
super();
this.onAllPagesDeleted = onAllPagesDeleted;
}
execute(): void {
@@ -99,7 +102,13 @@ export class DeletePagesCommand extends DOMCommand {
!this.pageIdsToDelete.includes(page.id)
);
if (remainingPages.length === 0) return; // Safety check
if (remainingPages.length === 0) {
// If all pages would be deleted, clear selection/splits and close PDF
this.setSelectedPages([]);
this.setSplitPositions(new Set());
this.onAllPagesDeleted?.();
return;
}
// Renumber remaining pages
remainingPages.forEach((page, index) => {