Split up AdvancedSelectionPanel and allow users to delete all selected pages

This commit is contained in:
EthanHealy01 2025-09-12 16:58:41 +01:00
parent 504c2d277b
commit f742d5cb1b
5 changed files with 256 additions and 214 deletions

View File

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

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Text, NumberInput, Group, Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
import classes from './BulkSelectionPanel.module.css'; import classes from './BulkSelectionPanel.module.css';
import { import {
appendExpression, appendExpression,
@ -9,6 +9,8 @@ import {
everyNthExpression, everyNthExpression,
rangeExpression, rangeExpression,
} from './BulkSelection'; } from './BulkSelection';
import SelectPages from './SelectPages';
import OperatorsSection from './OperatorsSection';
interface AdvancedSelectionPanelProps { interface AdvancedSelectionPanelProps {
csvInput: string; csvInput: string;
@ -25,16 +27,32 @@ const AdvancedSelectionPanel = ({
maxPages, maxPages,
advancedOpened, advancedOpened,
}: AdvancedSelectionPanelProps) => { }: AdvancedSelectionPanelProps) => {
// Visibility now controlled by parent
const [firstNValue, setFirstNValue] = useState<number | ''>('');
const [lastNValue, setLastNValue] = useState<number | ''>('');
const [everyNthValue, setEveryNthValue] = useState<number | ''>('');
const [rangeStart, setRangeStart] = useState<number | ''>('');
const [rangeEnd, setRangeEnd] = useState<number | ''>(''); const [rangeEnd, setRangeEnd] = useState<number | ''>('');
const [firstNError, setFirstNError] = useState<string | null>(null);
const [lastNError, setLastNError] = useState<string | null>(null);
const [rangeError, setRangeError] = useState<string | null>(null);
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;
};
const validateRangeEnd = (end: number): string | null => {
if (end <= 0) return 'Values must be positive';
return null;
};
// Named callback functions
const applyExpression = (expr: string) => { const applyExpression = (expr: string) => {
const nextInput = appendExpression(csvInput, expr); const nextInput = appendExpression(csvInput, expr);
setCsvInput(nextInput); setCsvInput(nextInput);
@ -46,6 +64,28 @@ const AdvancedSelectionPanel = ({
setCsvInput(next); setCsvInput(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 ( return (
<> <>
{/* Advanced section */} {/* Advanced section */}
@ -54,211 +94,47 @@ const AdvancedSelectionPanel = ({
<div className={classes.advancedContent}> <div className={classes.advancedContent}>
{/* Cards row */} {/* Cards row */}
<Flex direction="row" mb="xs" wrap="wrap"> <Flex direction="row" mb="xs" wrap="wrap">
{/* First N Pages - Card Style */} <SelectPages
<div className={classes.advancedCard}> title="First N Pages"
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">First N Pages</Text> placeholder="Number of pages"
{firstNError && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{firstNError}</Text>)} onApply={handleFirstNApply}
<div className={classes.inputGroup}> maxPages={maxPages}
<Group gap="sm" align="flex-end" wrap="nowrap"> validationFn={validatePositiveNumber}
<NumberInput />
size="sm"
value={firstNValue}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setFirstNValue(next);
if (next === '') setFirstNError(null);
else if (typeof next === 'number' && next <= 0) setFirstNError('Enter a positive number');
else setFirstNError(null);
}}
min={1}
placeholder="Number of pages"
className={classes.fullWidthInput}
error={Boolean(firstNError)}
/>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (!firstNValue || typeof firstNValue !== 'number') return;
const expr = firstNExpression(firstNValue, maxPages);
if (expr) applyExpression(expr);
setFirstNValue('');
}}
disabled={Boolean(firstNError) || firstNValue === ''}
>
Apply
</Button>
</Group>
</div>
</div>
{/* Range - Card Style */} <SelectPages
<div className={classes.advancedCard}> title="Range"
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">Range</Text> placeholder="From"
{rangeError && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{rangeError}</Text>)} onApply={handleRangeApply}
<div className={classes.inputGroup}> maxPages={maxPages}
<Group gap="sm" align="flex-end" wrap="nowrap"> validationFn={validateRangeStart}
<div style={{ flex: 1 }}> isRange={true}
<NumberInput rangeEndValue={rangeEnd}
size="sm" onRangeEndChange={handleRangeEndChange}
value={rangeStart} rangeEndPlaceholder="To"
onChange={(val) => { />
const next = typeof val === 'number' ? val : '';
setRangeStart(next);
const s = typeof next === 'number' ? next : null;
const e = typeof rangeEnd === 'number' ? rangeEnd : null;
if (s !== null && s <= 0) setRangeError('Values must be positive');
else if (s !== null && e !== null && s > e) setRangeError('From must be less than or equal to To');
else setRangeError(null);
}}
min={1}
placeholder="From"
error={Boolean(rangeError)}
/>
</div>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={rangeEnd}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setRangeEnd(next);
const e = typeof next === 'number' ? next : null;
const s = typeof rangeStart === 'number' ? rangeStart : null;
if (e !== null && e <= 0) setRangeError('Values must be positive');
else if (s !== null && e !== null && s > e) setRangeError('From must be less than or equal to To');
else setRangeError(null);
}}
min={1}
placeholder="To"
error={Boolean(rangeError)}
/>
</div>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (
rangeStart === '' || rangeEnd === '' ||
typeof rangeStart !== 'number' || typeof rangeEnd !== 'number'
) return;
const expr = rangeExpression(rangeStart, rangeEnd, maxPages);
if (expr) applyExpression(expr);
setRangeStart('');
setRangeEnd('');
}}
disabled={Boolean(rangeError) || rangeStart === '' || rangeEnd === ''}
>
Apply
</Button>
</Group>
</div>
</div>
{/* Last N Pages - Card Style */} <SelectPages
<div className={classes.advancedCard}> title="Last N Pages"
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">Last N Pages</Text> placeholder="Number of pages"
{lastNError && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{lastNError}</Text>)} onApply={handleLastNApply}
<div className={classes.inputGroup}> maxPages={maxPages}
<Group gap="sm" align="flex-end" wrap="nowrap"> validationFn={validatePositiveNumber}
<NumberInput />
size="sm"
value={lastNValue}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setLastNValue(next);
if (next === '') setLastNError(null);
else if (typeof next === 'number' && next <= 0) setLastNError('Enter a positive number');
else setLastNError(null);
}}
min={1}
placeholder="Number of pages"
className={classes.fullWidthInput}
error={Boolean(lastNError)}
/>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (!lastNValue || typeof lastNValue !== 'number') return;
const expr = lastNExpression(lastNValue, maxPages);
if (expr) applyExpression(expr);
setLastNValue('');
}}
disabled={Boolean(lastNError) || lastNValue === ''}
>
Apply
</Button>
</Group>
</div>
</div>
{/* Every Nth Page - Card Style */} <SelectPages
<div className={classes.advancedCard}> title="Every Nth Page"
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">Every Nth Page</Text> placeholder="Step size"
<div className={classes.inputGroup}> onApply={handleEveryNthApply}
<Group gap="sm" align="flex-end" wrap="nowrap"> maxPages={maxPages}
<NumberInput />
size="sm"
value={everyNthValue}
onChange={(val) => setEveryNthValue(typeof val === 'number' ? val : '')}
min={1}
placeholder="Step size"
className={classes.fullWidthInput}
/>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (!everyNthValue || typeof everyNthValue !== 'number') return;
const expr = everyNthExpression(everyNthValue);
if (expr) applyExpression(expr);
setEveryNthValue('');
}}
disabled={!everyNthValue}
>
Apply
</Button>
</Group>
</div>
</div>
</Flex> </Flex>
{/* Operators row at bottom */} {/* Operators row at bottom */}
<div> <OperatorsSection
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">Add Operators:</Text> csvInput={csvInput}
<Group gap="sm" wrap="nowrap"> onInsertOperator={insertOperator}
<Button />
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => insertOperator('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={() => insertOperator('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={() => insertOperator('not')}
disabled={!csvInput.trim()}
title="Exclude from selection"
>
<Text size="xs" fw={500}>not</Text>
</Button>
</Group>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -0,0 +1,49 @@
import { Button, Text, Group } from '@mantine/core';
import classes from './BulkSelectionPanel.module.css';
interface OperatorsSectionProps {
csvInput: string;
onInsertOperator: (op: 'and' | 'or' | 'not') => void;
}
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
return (
<div>
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">Add Operators:</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>
</div>
);
};
export default OperatorsSection;

View File

@ -0,0 +1,105 @@
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,
maxPages,
validationFn,
isRange = false,
rangeEndValue,
onRangeEndChange,
rangeEndPlaceholder,
}: SelectPagesProps) => {
const [value, setValue] = useState<number | ''>('');
const [error, setError] = useState<string | null>(null);
const handleValueChange = (val: string | number) => {
const next = typeof val === 'number' ? val : '';
setValue(next);
if (validationFn && typeof next === 'number') {
setError(validationFn(next));
} else {
setError(null);
}
};
const handleApply = () => {
if (value === '' || typeof value !== 'number') return;
onApply(value);
setValue('');
setError(null);
};
const isDisabled = Boolean(error) || value === '';
return (
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">{title}</Text>
{error && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{error}</Text>)}
<div className={classes.inputGroup}>
<Group gap="sm" align="flex-end" wrap="nowrap">
{isRange ? (
<>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
error={Boolean(error)}
/>
</div>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={rangeEndValue}
onChange={onRangeEndChange}
min={1}
placeholder={rangeEndPlaceholder}
error={Boolean(error)}
/>
</div>
</>
) : (
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
className={classes.fullWidthInput}
error={Boolean(error)}
/>
)}
<Button
size="sm"
className={classes.applyButton}
onClick={handleApply}
disabled={isDisabled}
>
Apply
</Button>
</Group>
</div>
</div>
);
};
export default SelectPages;

View File

@ -59,6 +59,7 @@ export class DeletePagesCommand extends DOMCommand {
private originalSelectedPages: number[] = []; private originalSelectedPages: number[] = [];
private hasExecuted: boolean = false; private hasExecuted: boolean = false;
private pageIdsToDelete: string[] = []; private pageIdsToDelete: string[] = [];
private onAllPagesDeleted?: () => void;
constructor( constructor(
private pagesToDelete: number[], private pagesToDelete: number[],
@ -67,9 +68,11 @@ export class DeletePagesCommand extends DOMCommand {
private setSelectedPages: (pages: number[]) => void, private setSelectedPages: (pages: number[]) => void,
private getSplitPositions: () => Set<number>, private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void, private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[] private getSelectedPages: () => number[],
onAllPagesDeleted?: () => void
) { ) {
super(); super();
this.onAllPagesDeleted = onAllPagesDeleted;
} }
execute(): void { execute(): void {
@ -99,7 +102,13 @@ export class DeletePagesCommand extends DOMCommand {
!this.pageIdsToDelete.includes(page.id) !this.pageIdsToDelete.includes(page.id)
); );
if (remainingPages.length === 0) return; // Safety check if (remainingPages.length === 0) {
// If all pages would be deleted, clear selection/splits and close PDF
this.setSelectedPages([]);
this.setSplitPositions(new Set());
this.onAllPagesDeleted?.();
return;
}
// Renumber remaining pages // Renumber remaining pages
remainingPages.forEach((page, index) => { remainingPages.forEach((page, index) => {