mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user