Compare commits

...

6 Commits

Author SHA1 Message Date
EthanHealy01
19eee1484b
Merge branch 'V2' into feature/V2/BulkSelectionPanel 2025-09-12 17:32:43 +01:00
James Brunton
cfdb6eaa1e
Add Adjust Page Scale tool to V2 (#4429)
# Description of Changes
Add Adjust Page Scale tool to V2
2025-09-12 17:25:22 +01:00
EthanHealy01
4add3ae774
Merge branch 'V2' into feature/V2/BulkSelectionPanel 2025-09-12 17:00:00 +01:00
EthanHealy01
cecf3ad12c Merge branch 'feature/V2/BulkSelectionPanel' of github.com:Stirling-Tools/Stirling-PDF into feature/V2/BulkSelectionPanel 2025-09-12 16:58:55 +01:00
EthanHealy01
f742d5cb1b Split up AdvancedSelectionPanel and allow users to delete all selected pages 2025-09-12 16:58:41 +01:00
James Brunton
8a367aab54
Change tips icon to i circle (#4430)
# Description of Changes

## Before

<img width="102" height="35" alt="image"
src="https://github.com/user-attachments/assets/fcb85906-85b6-41e1-9162-4084c0e684ec"
/>

## After

<img width="103" height="45" alt="image"
src="https://github.com/user-attachments/assets/241d61d8-d3c4-4dbf-a6af-4fda0867734d"
/>
2025-09-10 18:19:05 +01:00
16 changed files with 721 additions and 219 deletions

View File

@ -1510,7 +1510,6 @@
"submit": "Submit" "submit": "Submit"
}, },
"scalePages": { "scalePages": {
"tags": "resize,modify,dimension,adapt",
"title": "Adjust page-scale", "title": "Adjust page-scale",
"header": "Adjust page-scale", "header": "Adjust page-scale",
"pageSize": "Size of a page of the document.", "pageSize": "Size of a page of the document.",
@ -1518,6 +1517,44 @@
"scaleFactor": "Zoom level (crop) of a page.", "scaleFactor": "Zoom level (crop) of a page.",
"submit": "Submit" "submit": "Submit"
}, },
"adjustPageScale": {
"tags": "resize,modify,dimension,adapt",
"title": "Adjust Page Scale",
"header": "Adjust Page Scale",
"scaleFactor": {
"label": "Scale Factor"
},
"pageSize": {
"label": "Target Page Size",
"keep": "Keep Original Size",
"letter": "Letter",
"legal": "Legal"
},
"submit": "Adjust Page Scale",
"error": {
"failed": "An error occurred while adjusting the page scale."
},
"tooltip": {
"header": {
"title": "Page Scale Settings Overview"
},
"description": {
"title": "Description",
"text": "Adjust the size of PDF content and change the page dimensions."
},
"scaleFactor": {
"title": "Scale Factor",
"text": "Controls how large or small the content appears on the page. Content is scaled and centred - if scaled content is larger than the page size, it may be cropped.",
"bullet1": "1.0 = Original size",
"bullet2": "0.5 = Half size (50% smaller)",
"bullet3": "2.0 = Double size (200% larger, may crop)"
},
"pageSize": {
"title": "Target Page Size",
"text": "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, whilst other options resize to standard paper sizes."
}
}
},
"add-page-numbers": { "add-page-numbers": {
"tags": "paginate,label,organize,index" "tags": "paginate,label,organize,index"
}, },

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) => {

View File

@ -0,0 +1,64 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import AdjustPageScaleSettings from './AdjustPageScaleSettings';
import { AdjustPageScaleParameters, PageSize } from '../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string, fallback?: string) => fallback || `mock-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('AdjustPageScaleSettings', () => {
const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
};
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render without crashing', () => {
render(
<TestWrapper>
<AdjustPageScaleSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Basic render test - component renders without throwing
expect(screen.getByText('Scale Factor')).toBeInTheDocument();
expect(screen.getByText('Target Page Size')).toBeInTheDocument();
});
test('should render with custom parameters', () => {
const customParameters: AdjustPageScaleParameters = {
scaleFactor: 2.5,
pageSize: PageSize.A4,
};
render(
<TestWrapper>
<AdjustPageScaleSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Component renders successfully with custom parameters
expect(screen.getByText('Scale Factor')).toBeInTheDocument();
expect(screen.getByText('Target Page Size')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,55 @@
import { Stack, NumberInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AdjustPageScaleParameters, PageSize } from "../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
interface AdjustPageScaleSettingsProps {
parameters: AdjustPageScaleParameters;
onParameterChange: <K extends keyof AdjustPageScaleParameters>(key: K, value: AdjustPageScaleParameters[K]) => void;
disabled?: boolean;
}
const AdjustPageScaleSettings = ({ parameters, onParameterChange, disabled = false }: AdjustPageScaleSettingsProps) => {
const { t } = useTranslation();
const pageSizeOptions = [
{ value: PageSize.KEEP, label: t('adjustPageScale.pageSize.keep', 'Keep Original Size') },
{ value: PageSize.A0, label: 'A0' },
{ value: PageSize.A1, label: 'A1' },
{ value: PageSize.A2, label: 'A2' },
{ value: PageSize.A3, label: 'A3' },
{ value: PageSize.A4, label: 'A4' },
{ value: PageSize.A5, label: 'A5' },
{ value: PageSize.A6, label: 'A6' },
{ value: PageSize.LETTER, label: t('adjustPageScale.pageSize.letter', 'Letter') },
{ value: PageSize.LEGAL, label: t('adjustPageScale.pageSize.legal', 'Legal') },
];
return (
<Stack gap="md">
<NumberInput
label={t('adjustPageScale.scaleFactor.label', 'Scale Factor')}
value={parameters.scaleFactor}
onChange={(value) => onParameterChange('scaleFactor', typeof value === 'number' ? value : 1.0)}
min={0.1}
max={10.0}
step={0.1}
decimalScale={2}
disabled={disabled}
/>
<Select
label={t('adjustPageScale.pageSize.label', 'Target Page Size')}
value={parameters.pageSize}
onChange={(value) => {
if (value && Object.values(PageSize).includes(value as PageSize)) {
onParameterChange('pageSize', value as PageSize);
}
}}
data={pageSizeOptions}
disabled={disabled}
/>
</Stack>
);
};
export default AdjustPageScaleSettings;

View File

@ -54,7 +54,7 @@ const renderTooltipTitle = (
<Text fw={400} size="sm"> <Text fw={400} size="sm">
{title} {title}
</Text> </Text>
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} /> <LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
</Flex> </Flex>
</Tooltip> </Tooltip>
); );

View File

@ -22,7 +22,7 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT
<Text fw={500} size="lg" p="xs"> <Text fw={500} size="lg" p="xs">
{title} {title}
</Text> </Text>
{tooltip && <LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />} {tooltip && <LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
</Flex> </Flex>
); );

View File

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useAdjustPageScaleTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("adjustPageScale.tooltip.header.title", "Page Scale Settings Overview")
},
tips: [
{
title: t("adjustPageScale.tooltip.description.title", "Description"),
description: t("adjustPageScale.tooltip.description.text", "Adjust the size of PDF content and change the page dimensions.")
},
{
title: t("adjustPageScale.tooltip.scaleFactor.title", "Scale Factor"),
description: t("adjustPageScale.tooltip.scaleFactor.text", "Controls how large or small the content appears on the page. Content is scaled and centered - if scaled content is larger than the page size, it may be cropped."),
bullets: [
t("adjustPageScale.tooltip.scaleFactor.bullet1", "1.0 = Original size"),
t("adjustPageScale.tooltip.scaleFactor.bullet2", "0.5 = Half size (50% smaller)"),
t("adjustPageScale.tooltip.scaleFactor.bullet3", "2.0 = Double size (200% larger, may crop)")
]
},
{
title: t("adjustPageScale.tooltip.pageSize.title", "Target Page Size"),
description: t("adjustPageScale.tooltip.pageSize.text", "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, while other options resize to standard paper sizes.")
}
]
};
};

View File

@ -49,8 +49,11 @@ import ChangePermissionsSettings from "../components/tools/changePermissions/Cha
import FlattenSettings from "../components/tools/flatten/FlattenSettings"; import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings"; import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import Redact from "../tools/Redact"; import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import { ToolId } from "../types/toolId"; import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings'; import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -337,11 +340,14 @@ export function useFlatToolRegistry(): ToolRegistry {
"adjust-page-size-scale": { "adjust-page-size-scale": {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scalePages.title", "Adjust page size/scale"), name: t("home.scalePages.title", "Adjust page size/scale"),
component: null, component: AdjustPageScale,
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."), description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING, subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
settingsComponent: AdjustPageScaleSettings,
}, },
addPageNumbers: { addPageNumbers: {
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AdjustPageScaleParameters, defaultParameters } from './useAdjustPageScaleParameters';
export const buildAdjustPageScaleFormData = (parameters: AdjustPageScaleParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("scaleFactor", parameters.scaleFactor.toString());
formData.append("pageSize", parameters.pageSize);
return formData;
};
export const adjustPageScaleOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAdjustPageScaleFormData,
operationType: 'adjustPageScale',
endpoint: '/api/v1/general/scale-pages',
filePrefix: 'scaled_',
defaultParameters,
} as const;
export const useAdjustPageScaleOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustPageScaleParameters>({
...adjustPageScaleOperationConfig,
getErrorMessage: createStandardErrorHandler(t('adjustPageScale.error.failed', 'An error occurred while adjusting the page scale.'))
});
};

View File

@ -0,0 +1,142 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from './useAdjustPageScaleParameters';
describe('useAdjustPageScaleParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
expect(result.current.parameters.pageSize).toBe(PageSize.KEEP);
});
test.each([
{ paramName: 'scaleFactor' as const, value: 0.5 },
{ paramName: 'scaleFactor' as const, value: 2.0 },
{ paramName: 'scaleFactor' as const, value: 10.0 },
{ paramName: 'pageSize' as const, value: PageSize.A4 },
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('scaleFactor', 2.5);
result.current.updateParameter('pageSize', PageSize.A3);
});
expect(result.current.parameters.scaleFactor).toBe(2.5);
expect(result.current.parameters.pageSize).toBe(PageSize.A3);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.getEndpointName()).toBe('scale-pages');
});
test.each([
{
description: 'with default parameters',
setup: () => {},
expected: true
},
{
description: 'with valid scale factor 0.1',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 0.1);
},
expected: true
},
{
description: 'with valid scale factor 10.0',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 10.0);
},
expected: true
},
{
description: 'with A4 page size',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('pageSize', PageSize.A4);
},
expected: true
},
{
description: 'with invalid scale factor 0',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 0);
},
expected: false
},
{
description: 'with negative scale factor',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', -0.5);
},
expected: false
}
])('should validate parameters correctly $description', ({ setup, expected }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should handle all PageSize enum values', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
Object.values(PageSize).forEach(pageSize => {
act(() => {
result.current.updateParameter('pageSize', pageSize);
});
expect(result.current.parameters.pageSize).toBe(pageSize);
expect(result.current.validateParameters()).toBe(true);
});
});
test('should handle scale factor edge cases', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// Test very small valid scale factor
act(() => {
result.current.updateParameter('scaleFactor', 0.01);
});
expect(result.current.validateParameters()).toBe(true);
// Test scale factor just above zero
act(() => {
result.current.updateParameter('scaleFactor', 0.001);
});
expect(result.current.validateParameters()).toBe(true);
// Test exactly zero (invalid)
act(() => {
result.current.updateParameter('scaleFactor', 0);
});
expect(result.current.validateParameters()).toBe(false);
});
});

View File

@ -0,0 +1,37 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export enum PageSize {
KEEP = 'KEEP',
A0 = 'A0',
A1 = 'A1',
A2 = 'A2',
A3 = 'A3',
A4 = 'A4',
A5 = 'A5',
A6 = 'A6',
LETTER = 'LETTER',
LEGAL = 'LEGAL'
}
export interface AdjustPageScaleParameters extends BaseParameters {
scaleFactor: number;
pageSize: PageSize;
}
export const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
};
export type AdjustPageScaleParametersHook = BaseParametersHook<AdjustPageScaleParameters>;
export const useAdjustPageScaleParameters = (): AdjustPageScaleParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'scale-pages',
validateFn: (params) => {
return params.scaleFactor > 0;
},
});
};

View File

@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
import { useAdjustPageScaleParameters } from "../hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
import { useAdjustPageScaleOperation } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useAdjustPageScaleTips } from "../components/tooltips/useAdjustPageScaleTips";
const AdjustPageScale = (props: BaseToolProps) => {
const { t } = useTranslation();
const adjustPageScaleTips = useAdjustPageScaleTips();
const base = useBaseTool(
'adjustPageScale',
useAdjustPageScaleParameters,
useAdjustPageScaleOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: adjustPageScaleTips,
content: (
<AdjustPageScaleSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("adjustPageScale.submit", "Adjust Page Scale"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("adjustPageScale.title", "Page Scale Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default AdjustPageScale as ToolComponent;