mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/extract pages (#4828)
# Description of Changes - Add the extract pages tool - Componentize our bulk selection logic and warning messaages --- ## 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) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### 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,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
|
||||
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
|
||||
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
|
||||
import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint';
|
||||
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
@@ -20,26 +20,9 @@ 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('');
|
||||
@@ -56,10 +39,12 @@ const BulkSelectionPanel = ({
|
||||
onToggleAdvanced={setAdvancedOpened}
|
||||
/>
|
||||
|
||||
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
|
||||
|
||||
<SelectedPagesDisplay
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
syntaxError={syntaxError}
|
||||
syntaxError={null}
|
||||
/>
|
||||
|
||||
<AdvancedSelectionPanel
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
||||
everyNthExpression,
|
||||
rangeExpression,
|
||||
LogicalOperator,
|
||||
} from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
|
||||
} from '@app/utils/bulkselection/selectionBuilders';
|
||||
import SelectPages from '@app/components/pageEditor/bulkSelectionPanel/SelectPages';
|
||||
import OperatorsSection from '@app/components/pageEditor/bulkSelectionPanel/OperatorsSection';
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -252,6 +252,25 @@
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
/* Compact error container for inline tool settings */
|
||||
.errorCompact {
|
||||
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;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Two-line clamp for compact error text */
|
||||
.errorTextClamp {
|
||||
color: var(--text-brand-accent);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dark-mode adjustments */
|
||||
:global([data-mantine-color-scheme='dark']) .selectedList {
|
||||
background-color: var(--bg-raised);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Text, Group, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import { LogicalOperator } from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
|
||||
import { LogicalOperator } from '@app/utils/bulkselection/selectionBuilders';
|
||||
|
||||
interface OperatorsSectionProps {
|
||||
csvInput: string;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text } from '@mantine/core';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
|
||||
|
||||
interface PageSelectionSyntaxHintProps {
|
||||
input: string;
|
||||
/** Optional known page count; if not provided, a large max is used for syntax-only checks */
|
||||
maxPages?: number;
|
||||
/** panel = full bulk panel style, compact = inline tool style */
|
||||
variant?: 'panel' | 'compact';
|
||||
}
|
||||
|
||||
const FALLBACK_MAX_PAGES = 100000; // large upper bound for syntax validation without a document
|
||||
|
||||
const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSelectionSyntaxHintProps) => {
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const text = (input || '').trim();
|
||||
if (!text) {
|
||||
setSyntaxError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES);
|
||||
setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null);
|
||||
} catch {
|
||||
setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.'));
|
||||
}
|
||||
}, [input, maxPages]);
|
||||
|
||||
if (!syntaxError) return null;
|
||||
|
||||
return (
|
||||
<div className={variant === 'panel' ? classes.selectedList : classes.errorCompact}>
|
||||
<Text size="xs" className={variant === 'panel' ? classes.errorText : classes.errorTextClamp}>{syntaxError}</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSelectionSyntaxHint;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters";
|
||||
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
|
||||
|
||||
interface ExtractPagesSettingsProps {
|
||||
parameters: ExtractPagesParameters;
|
||||
onParameterChange: <K extends keyof ExtractPagesParameters>(key: K, value: ExtractPagesParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ExtractPagesSettings = ({ parameters, onParameterChange, disabled = false }: ExtractPagesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
onParameterChange('pageNumbers', value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('extractPages.pageNumbers.label', 'Pages to Extract')}
|
||||
value={parameters.pageNumbers || ''}
|
||||
onChange={(event) => handleChange(event.currentTarget.value)}
|
||||
placeholder={t('extractPages.pageNumbers.placeholder', 'e.g., 1,3,5-8 or odd & 1-10')}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
<PageSelectionSyntaxHint input={parameters.pageNumbers || ''} variant="compact" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtractPagesSettings;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
import { usePageSelectionTips } from '@app/components/tooltips/usePageSelectionTips';
|
||||
|
||||
export const useExtractPagesTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
const base = usePageSelectionTips();
|
||||
|
||||
return {
|
||||
header: base.header,
|
||||
tips: [
|
||||
{
|
||||
description: t('extractPages.tooltip.description', 'Extracts the selected pages into a new PDF, preserving order.')
|
||||
},
|
||||
...(base.tips || [])
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export default useExtractPagesTips;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user