Compare commits

...

14 Commits

Author SHA1 Message Date
Reece Browne
5d7fb638af
Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 15:31:45 +01:00
James Brunton
7dad484aa7
Improve type info on param hooks (#4438)
# Description of Changes
Changes it so that callers of `useBaseTool` know what actual type the
parameters hook that they passed in returned, so they can actually make
use of any extra methods that that params hook has.
2025-09-15 14:28:18 +01:00
Reece Browne
2fb4710dd7
Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 13:34:00 +01:00
Reece Browne
85a74c1d46 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-15 13:33:45 +01:00
Reece Browne
21a93d6cac Context based right rail controls for viewer 2025-09-15 13:33:39 +01:00
Reece Browne
9599bca8a9
Update frontend/src/components/viewer/ThumbnailSidebar.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-15 12:37:07 +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
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
James Brunton
f3fd85d777
Add Merge UI to V2 (#4235)
# Description of Changes
Add UI for Merge into V2.
2025-09-10 13:06:23 +00:00
James Brunton
9d723eae69
Add auto-redact to V2 (#4417)
# Description of Changes
Adds auto-redact tool to V2, with manual-redact in the UI but explicitly
disabled.

Also creates a shared component for the large buttons we're using in a
couple different tools and uses consistently.
2025-09-10 14:03:11 +01:00
James Brunton
494ef801a2
Improve npm scripts (#4424)
# Description of Changes
Change NPM scripts so they call each other (single source of truth) and
add a command to run type checking, linting and tests (to give
confidence CI will pass).
2025-09-09 16:18:09 +01:00
Ludy
e8af4f6b35
Set i18n to load only current language (#4359)
This pull request introduces a minor configuration change to the i18n
setup in the frontend. The change improves language loading behavior by
ensuring only the current language is loaded, which can help optimize
performance and prevent unnecessary resource usage.

* Added the `load: 'currentOnly'` option to the i18n initialization in
`frontend/src/i18n.ts`, so only the current language is loaded.

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-09-08 10:05:49 +01:00
stirlingbot[bot]
c25985e49e
Update Frontend 3rd Party Licenses (#4319)
Auto-generated by stirlingbot[bot]

This PR updates the frontend license report based on changes to
package.json dependencies.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-09-08 08:58:22 +00:00
James Brunton
316be5eac5
Fix types of onParameterChange methods (#4415)
# Description of Changes
Fix types of onParameterChange methods
2025-09-08 09:55:30 +01:00
79 changed files with 3713 additions and 950 deletions

View File

@ -52,16 +52,18 @@
},
"scripts": {
"predev": "npm run generate-icons",
"dev": "npx tsc --noEmit && vite",
"dev": "npm run typecheck && vite",
"prebuild": "npm run generate-icons",
"lint": "npx eslint",
"build": "npx tsc --noEmit && vite build",
"lint": "eslint",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
"test": "vitest",
"test:run": "vitest run",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",

View File

@ -51,11 +51,11 @@
"filesSelected": "{{count}} files selected",
"files": {
"title": "Files",
"placeholder": "Select a PDF file in the main view to get started",
"upload": "Upload",
"uploadFiles": "Upload Files",
"addFiles": "Add files",
"selectFromWorkbench": "Select files from the workbench or "
"selectFromWorkbench": "Select files from the workbench or ",
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
},
"noFavourites": "No favourites added",
"downloadComplete": "Download Complete",
@ -498,13 +498,9 @@
"title": "Show Javascript",
"desc": "Searches and displays any JS injected into a PDF"
},
"autoRedact": {
"title": "Auto Redact",
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
},
"redact": {
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlay-pdfs": {
"title": "Overlay PDFs",
@ -648,11 +644,29 @@
"merge": {
"tags": "merge,Page operations,Back end,server side",
"title": "Merge",
"header": "Merge multiple PDFs (2+)",
"sortByName": "Sort by name",
"sortByDate": "Sort by date",
"removeCertSign": "Remove digital signature in the merged file?",
"submit": "Merge"
"removeDigitalSignature": "Remove digital signature in the merged file?",
"generateTableOfContents": "Generate table of contents in the merged file?",
"removeDigitalSignature.tooltip": {
"title": "Remove Digital Signature",
"description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF."
},
"generateTableOfContents.tooltip": {
"title": "Generate Table of Contents",
"description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers."
},
"submit": "Merge",
"sortBy": {
"description": "Files will be merged in the order they're selected. Drag to reorder or sort below.",
"label": "Sort By",
"filename": "File Name",
"dateModified": "Date Modified",
"ascending": "Ascending",
"descending": "Descending",
"sort": "Sort"
},
"error": {
"failed": "An error occurred while merging the PDFs."
}
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
@ -1454,7 +1468,6 @@
"submit": "Submit"
},
"scalePages": {
"tags": "resize,modify,dimension,adapt",
"title": "Adjust page-scale",
"header": "Adjust page-scale",
"pageSize": "Size of a page of the document.",
@ -1462,6 +1475,44 @@
"scaleFactor": "Zoom level (crop) of a page.",
"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": {
"tags": "paginate,label,organize,index"
},
@ -1583,25 +1634,97 @@
"downloadJS": "Download Javascript",
"submit": "Show"
},
"autoRedact": {
"tags": "Redact,Hide,black out,black,marker,hidden",
"title": "Auto Redact",
"redact": {
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
"title": "Redact",
"submit": "Redact",
"error": {
"failed": "An error occurred while redacting the PDF."
},
"modeSelector": {
"title": "Redaction Method",
"mode": "Mode",
"automatic": "Automatic",
"automaticDesc": "Redact text based on search terms",
"manual": "Manual",
"manualDesc": "Click and drag to redact specific areas",
"manualComingSoon": "Manual redaction coming soon"
},
"auto": {
"header": "Auto Redact",
"colorLabel": "Colour",
"textsToRedactLabel": "Text to Redact (line-separated)",
"textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-Secret",
"settings": {
"title": "Redaction Settings",
"advancedTitle": "Advanced"
},
"colorLabel": "Box Colour",
"wordsToRedact": {
"title": "Words to Redact",
"placeholder": "Enter a word",
"add": "Add",
"examples": "Examples: Confidential, Top-Secret"
},
"useRegexLabel": "Use Regex",
"wholeWordSearchLabel": "Whole Word Search",
"customPaddingLabel": "Custom Extra Padding",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"submitButton": "Submit"
"convertPDFToImageLabel": "Convert PDF to PDF-Image"
},
"redact": {
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
"tooltip": {
"mode": {
"header": {
"title": "Redaction Method"
},
"automatic": {
"title": "Automatic Redaction",
"text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers."
},
"manual": {
"title": "Manual Redaction",
"text": "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)"
}
},
"words": {
"header": {
"title": "Words to Redact"
},
"description": {
"title": "Text Matching",
"text": "Enter words or phrases to find and redact in your document. Each word will be searched for separately."
},
"bullet1": "Add one word at a time",
"bullet2": "Press Enter or click 'Add Another' to add",
"bullet3": "Click × to remove words",
"examples": {
"title": "Common Examples",
"text": "Typical words to redact include: bank details, email addresses, or specific names."
}
},
"advanced": {
"header": {
"title": "Advanced Redaction Settings"
},
"color": {
"title": "Box Colour & Padding",
"text": "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."
},
"regex": {
"title": "Use Regex",
"text": "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns.",
"bullet1": "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format",
"bullet2": "Use with caution - test thoroughly"
},
"wholeWord": {
"title": "Whole Word Search",
"text": "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled."
},
"convert": {
"title": "Convert to PDF-Image",
"text": "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
}
}
},
"manual": {
"header": "Manual Redaction",
"submit": "Redact",
"textBasedRedaction": "Text based Redaction",
"textBasedRedaction": "Text-based Redaction",
"pageBasedRedaction": "Page-based Redaction",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"pageRedactionNumbers": {
@ -1609,7 +1732,7 @@
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
},
"redactionColor": {
"title": "Redaction Color"
"title": "Redaction Colour"
},
"export": "Export",
"upload": "Upload",
@ -1622,11 +1745,12 @@
"toggleSidebar": "Toggle Sidebar",
"showThumbnails": "Show Thumbnails",
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
"showAttatchments": "Show Attachments",
"showAttachments": "Show Attachments",
"showLayers": "Show Layers (double-click to reset all layers to the default state)",
"colourPicker": "Colour Picker",
"findCurrentOutlineItem": "Find current outline item",
"applyChanges": "Apply Changes"
}
},
"tableExtraxt": {
"tags": "CSV,Table Extraction,extract,convert"
@ -1837,6 +1961,11 @@
"title": "Compress",
"desc": "Compress PDFs to reduce their file size.",
"header": "Compress PDF",
"method": {
"title": "Compression Method",
"quality": "Quality",
"filesize": "File Size"
},
"credit": "This service uses qpdf for PDF Compress/Optimisation.",
"grayscale": {
"label": "Apply Grayscale for Compression"

View File

@ -385,6 +385,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@posthog/core",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tailwindcss/node",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
@ -742,6 +749,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "core-js",
"moduleUrl": "https://github.com/zloirock/core-js",
"moduleVersion": "3.45.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "core-util-is",
"moduleUrl": "https://github.com/isaacs/core-util-is",
@ -924,6 +938,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "fflate",
"moduleUrl": "https://github.com/101arrowz/fflate",
"moduleVersion": "0.4.8",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "file-selector",
"moduleUrl": "https://github.com/react-dropzone/file-selector",
@ -1533,6 +1554,20 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "posthog-js",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.261.0",
"moduleLicense": "MIT*",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "preact",
"moduleUrl": "https://github.com/preactjs/preact",
"moduleVersion": "10.27.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "pretty-format",
"moduleUrl": "https://github.com/facebook/jest",
@ -1928,7 +1963,7 @@
{
"moduleName": "typescript",
"moduleUrl": "https://github.com/microsoft/TypeScript",
"moduleVersion": "5.8.3",
"moduleVersion": "5.9.2",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@ -1995,6 +2030,13 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "web-vitals",
"moduleUrl": "https://github.com/GoogleChrome/web-vitals",
"moduleVersion": "4.2.4",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "webidl-conversions",
"moduleUrl": "https://github.com/jsdom/webidl-conversions",

View File

@ -0,0 +1,216 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import ButtonSelector from './ButtonSelector';
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('ButtonSelector', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all options as buttons', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Test Label"
/>
</TestWrapper>
);
expect(screen.getByText('Test Label')).toBeInTheDocument();
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
});
test('should highlight selected button with filled variant', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Selection Label"
/>
</TestWrapper>
);
const selectedButton = screen.getByRole('button', { name: 'Option 1' });
const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
// Check data-variant attribute for filled/outline
expect(selectedButton).toHaveAttribute('data-variant', 'filled');
expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
expect(screen.getByText('Selection Label')).toBeInTheDocument();
});
test('should call onChange when button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).toHaveBeenCalledWith('option2');
});
test('should handle undefined value (no selection)', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value={undefined}
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Both buttons should be outlined when no value is selected
const button1 = screen.getByRole('button', { name: 'Option 1' });
const button2 = screen.getByRole('button', { name: 'Option 2' });
expect(button1).toHaveAttribute('data-variant', 'outline');
expect(button2).toHaveAttribute('data-variant', 'outline');
});
test.each([
{
description: 'disable buttons when disabled prop is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
globalDisabled: true,
expectedStates: [true, true],
},
{
description: 'disable individual options when option.disabled is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
],
globalDisabled: false,
expectedStates: [false, true],
},
])('should $description', ({ options, globalDisabled, expectedStates }) => {
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
disabled={globalDisabled}
/>
</TestWrapper>
);
options.forEach((option, index) => {
const button = screen.getByRole('button', { name: option.label });
expect(button).toHaveProperty('disabled', expectedStates[index]);
});
});
test('should not call onChange when disabled button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).not.toHaveBeenCalled();
});
test('should not apply fullWidth styling when fullWidth is false', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
fullWidth={false}
label="Layout Label"
/>
</TestWrapper>
);
const button = screen.getByRole('button', { name: 'Option 1' });
expect(button).not.toHaveStyle({ flex: '1' });
expect(screen.getByText('Layout Label')).toBeInTheDocument();
});
test('should not render label element when not provided', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Should render buttons
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
// Stack should only contain the Group (buttons), no Text element for label
const stackElement = container.querySelector('[class*="mantine-Stack-root"]');
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
});
});

View File

@ -0,0 +1,59 @@
import { Button, Group, Stack, Text } from "@mantine/core";
export interface ButtonOption<T> {
value: T;
label: string;
disabled?: boolean;
}
interface ButtonSelectorProps<T> {
value: T | undefined;
onChange: (value: T) => void;
options: ButtonOption<T>[];
label?: string;
disabled?: boolean;
fullWidth?: boolean;
}
const ButtonSelector = <T extends string>({
value,
onChange,
options,
label = undefined,
disabled = false,
fullWidth = true,
}: ButtonSelectorProps<T>) => {
return (
<Stack gap='var(--mantine-spacing-sm)'>
{/* Label (if it exists) */}
{label && <Text style={{
fontSize: "var(--mantine-font-size-sm)",
lineHeight: "var(--mantine-line-height-sm)",
fontWeight: "var(--font-weight-medium)",
}}>{label}</Text>}
{/* Buttons */}
<Group gap='4px'>
{options.map((option) => (
<Button
key={option.value}
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
minHeight: '2.5rem',
fontSize: 'var(--mantine-font-size-sm)'
}}
>
{option.label}
</Button>
))}
</Group>
</Stack>
);
};
export default ButtonSelector;

View File

@ -4,7 +4,7 @@ import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPa
interface AddPasswordSettingsProps {
parameters: AddPasswordParameters;
onParameterChange: (key: keyof AddPasswordParameters, value: any) => void;
onParameterChange: <K extends keyof AddPasswordParameters>(key: K, value: AddPasswordParameters[K]) => void;
disabled?: boolean;
}

View File

@ -1,5 +1,5 @@
import { Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ButtonSelector from "../../shared/ButtonSelector";
interface WatermarkTypeSettingsProps {
watermarkType?: 'text' | 'image';
@ -11,32 +11,21 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
const { t } = useTranslation();
return (
<Stack gap="sm">
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={watermarkType === 'text' ? 'filled' : 'outline'}
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('text')}
<ButtonSelector
value={watermarkType}
onChange={onWatermarkTypeChange}
options={[
{
value: 'text',
label: t('watermark.watermarkType.text', 'Text'),
},
{
value: 'image',
label: t('watermark.watermarkType.image', 'Image'),
},
]}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.text', 'Text')}
</div>
</Button>
<Button
variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.image', 'Image')}
</div>
</Button>
</div>
</Stack>
/>
);
};

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

@ -4,7 +4,7 @@ import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissi
interface ChangePermissionsSettingsProps {
parameters: ChangePermissionsParameters;
onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void;
onParameterChange: <K extends keyof ChangePermissionsParameters>(key: K, value: ChangePermissionsParameters[K]) => void;
disabled?: boolean;
}

View File

@ -1,11 +1,12 @@
import React, { useState } from "react";
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
import ButtonSelector from "../../shared/ButtonSelector";
interface CompressSettingsProps {
parameters: CompressParameters;
onParameterChange: (key: keyof CompressParameters, value: any) => void;
onParameterChange: <K extends keyof CompressParameters>(key: K, value: CompressParameters[K]) => void;
disabled?: boolean;
}
@ -18,33 +19,16 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<Divider ml='-md'></Divider>
{/* Compression Method */}
<Stack gap="sm">
<Text size="sm" fw={500}>Compression Method</Text>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'quality')}
<ButtonSelector
label={t('compress.method.title', 'Compression Method')}
value={parameters.compressionMethod}
onChange={(value) => onParameterChange('compressionMethod', value)}
options={[
{ value: 'quality', label: t('compress.method.quality', 'Quality') },
{ value: 'filesize', label: t('compress.method.filesize', 'File Size') },
]}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Quality
</div>
</Button>
<Button
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'filesize')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
File Size
</div>
</Button>
</div>
</Stack>
/>
{/* Quality Adjustment */}
{parameters.compressionMethod === 'quality' && (

View File

@ -5,7 +5,7 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromEmailSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}

View File

@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertFromImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}

View File

@ -5,7 +5,7 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromWebSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}

View File

@ -26,7 +26,7 @@ import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: StirlingFile[];
disabled?: boolean;

View File

@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertToImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}

View File

@ -7,7 +7,7 @@ import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
selectedFiles: StirlingFile[];
disabled?: boolean;
}

View File

@ -0,0 +1,182 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import MergeFileSorter from './MergeFileSorter';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `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('MergeFileSorter', () => {
const mockOnSortFiles = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render sort options dropdown, direction toggle, and sort button', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// Should have a select dropdown (Mantine Select uses textbox role)
expect(screen.getByRole('textbox')).toBeInTheDocument();
// Should have direction toggle button
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2); // ActionIcon + Sort Button
// Should have sort button with text
expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument();
});
test('should render description text', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
});
test('should have filename selected by default', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const select = screen.getByRole('textbox');
expect(select).toHaveValue('mock-merge.sortBy.filename');
});
test('should show ascending direction by default', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// Should show ascending arrow icon
const directionButton = screen.getAllByRole('button')[0];
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
});
test('should toggle direction when direction button is clicked', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const directionButton = screen.getAllByRole('button')[0];
// Initially ascending
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
// Click to toggle to descending
fireEvent.click(directionButton);
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending');
// Click again to toggle back to ascending
fireEvent.click(directionButton);
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
});
test('should call onSortFiles with correct parameters when sort button is clicked', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const sortButton = screen.getByText('mock-merge.sortBy.sort');
fireEvent.click(sortButton);
// Should be called with default values (filename, ascending)
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true);
});
test('should call onSortFiles with dateModified when dropdown is changed', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// Open the dropdown by clicking on the current selected value
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
fireEvent.mouseDown(currentSelection);
// Click on the dateModified option
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
fireEvent.click(dateModifiedOption);
const sortButton = screen.getByText('mock-merge.sortBy.sort');
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
});
test('should call onSortFiles with descending direction when toggled', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const directionButton = screen.getAllByRole('button')[0];
const sortButton = screen.getByText('mock-merge.sortBy.sort');
// Toggle to descending
fireEvent.click(directionButton);
// Click sort
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false);
});
test('should handle complex user interaction sequence', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const directionButton = screen.getAllByRole('button')[0];
const sortButton = screen.getByText('mock-merge.sortBy.sort');
// 1. Change to dateModified
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
fireEvent.mouseDown(currentSelection);
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
fireEvent.click(dateModifiedOption);
// 2. Toggle to descending
fireEvent.click(directionButton);
// 3. Click sort
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false);
// 4. Toggle back to ascending
fireEvent.click(directionButton);
// 5. Sort again
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
});
});

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import SortIcon from '@mui/icons-material/Sort';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
interface MergeFileSorterProps {
onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void;
disabled?: boolean;
}
const MergeFileSorter: React.FC<MergeFileSorterProps> = ({
onSortFiles,
disabled = false,
}) => {
const { t } = useTranslation();
const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename');
const [ascending, setAscending] = useState(true);
const sortOptions = [
{ value: 'filename', label: t('merge.sortBy.filename', 'File Name') },
{ value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') },
];
const handleSort = () => {
onSortFiles(sortType, ascending);
};
const handleDirectionToggle = () => {
setAscending(!ascending);
};
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
</Text>
<Stack gap="xs">
<Group gap="xs" align="end" justify="space-between">
<Select
data={sortOptions}
value={sortType}
onChange={(value) => setSortType(value as 'filename' | 'dateModified')}
disabled={disabled}
label={t('merge.sortBy.label', 'Sort By')}
size='xs'
style={{ flex: 1 }}
/>
<ActionIcon
variant="light"
size="md"
onClick={handleDirectionToggle}
disabled={disabled}
title={ascending ? t('merge.sortBy.ascending', 'Ascending') : t('merge.sortBy.descending', 'Descending')}
>
{ascending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
</ActionIcon>
</Group>
<Button
variant="light"
size="xs"
leftSection={<SortIcon />}
onClick={handleSort}
disabled={disabled}
fullWidth
>
{t('merge.sortBy.sort', 'Sort')}
</Button>
</Stack>
</Stack>
);
};
export default MergeFileSorter;

View File

@ -0,0 +1,100 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import MergeSettings from './MergeSettings';
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `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('MergeSettings', () => {
const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render both merge option checkboxes', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Should render one checkbox for each parameter
const expectedCheckboxCount = Object.keys(defaultParameters).length;
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(expectedCheckboxCount);
});
test('should show correct initial checkbox states based on parameters', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
// Both checkboxes should be unchecked initially
checkboxes.forEach(checkbox => {
expect(checkbox).not.toBeChecked();
});
});
test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
// Click the first checkbox (removeDigitalSignature - should toggle from false to true)
fireEvent.click(checkboxes[0]);
expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
// Click the second checkbox (generateTableOfContents - should toggle from false to true)
fireEvent.click(checkboxes[1]);
expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
});
test('should call translation function with correct keys', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Verify that translation keys are being called
expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
});
});

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
interface MergeSettingsProps {
parameters: MergeParameters;
onParameterChange: <K extends keyof MergeParameters>(key: K, value: MergeParameters[K]) => void;
disabled?: boolean;
}
const MergeSettings: React.FC<MergeSettingsProps> = ({
parameters,
onParameterChange,
disabled = false,
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Checkbox
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
checked={parameters.removeDigitalSignature}
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')}
checked={parameters.generateTableOfContents}
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default MergeSettings;

View File

@ -16,7 +16,7 @@ interface AdvancedOption {
interface AdvancedOCRSettingsProps {
advancedOptions: string[];
ocrRenderType?: string;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
disabled?: boolean;
}

View File

@ -6,7 +6,7 @@ import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
interface OCRSettingsProps {
parameters: OCRParameters;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
disabled?: boolean;
}

View File

@ -0,0 +1,211 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import RedactAdvancedSettings from './RedactAdvancedSettings';
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
// Mock useTranslation
const mockT = vi.fn((_key: string, fallback: string) => fallback);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('RedactAdvancedSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all advanced settings controls', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('Box Colour')).toBeInTheDocument();
expect(screen.getByText('Custom Extra Padding')).toBeInTheDocument();
expect(screen.getByText('Use Regex')).toBeInTheDocument();
expect(screen.getByText('Whole Word Search')).toBeInTheDocument();
expect(screen.getByText('Convert PDF to PDF-Image (Used to remove text behind the box)')).toBeInTheDocument();
});
test('should display current parameter values', () => {
const customParameters = {
...defaultParameters,
redactColor: '#FF0000',
customPadding: 0.5,
useRegex: true,
wholeWordSearch: true,
convertPDFToImage: false,
};
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check color input value
const colorInput = screen.getByDisplayValue('#FF0000');
expect(colorInput).toBeInTheDocument();
// Check number input value
const paddingInput = screen.getByDisplayValue('0.5');
expect(paddingInput).toBeInTheDocument();
// Check checkbox states
const useRegexCheckbox = screen.getByLabelText('Use Regex');
const wholeWordCheckbox = screen.getByLabelText('Whole Word Search');
const convertCheckbox = screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)');
expect(useRegexCheckbox).toBeChecked();
expect(wholeWordCheckbox).toBeChecked();
expect(convertCheckbox).not.toBeChecked();
});
test('should call onParameterChange when color is changed', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const colorInput = screen.getByDisplayValue('#000000');
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
});
test('should call onParameterChange when padding is changed', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const paddingInput = screen.getByDisplayValue('0.1');
fireEvent.change(paddingInput, { target: { value: '0.5' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.5);
});
test('should handle invalid padding values', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const paddingInput = screen.getByDisplayValue('0.1');
// Simulate NumberInput onChange with invalid value (empty string)
const numberInput = paddingInput.closest('.mantine-NumberInput-root');
if (numberInput) {
// Find the input and trigger change with empty value
fireEvent.change(paddingInput, { target: { value: '' } });
// The component should default to 0.1 for invalid values
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.1);
}
});
test.each([
{
paramName: 'useRegex' as const,
label: 'Use Regex',
initialValue: false,
expectedValue: true,
},
{
paramName: 'wholeWordSearch' as const,
label: 'Whole Word Search',
initialValue: false,
expectedValue: true,
},
{
paramName: 'convertPDFToImage' as const,
label: 'Convert PDF to PDF-Image (Used to remove text behind the box)',
initialValue: true,
expectedValue: false,
},
])('should call onParameterChange when $paramName checkbox is toggled', ({ paramName, label, initialValue, expectedValue }) => {
const customParameters = {
...defaultParameters,
[paramName]: initialValue,
};
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkbox = screen.getByLabelText(label);
fireEvent.click(checkbox);
expect(mockOnParameterChange).toHaveBeenCalledWith(paramName, expectedValue);
});
test.each([
{ controlType: 'color input', getValue: () => screen.getByDisplayValue('#000000') },
{ controlType: 'padding input', getValue: () => screen.getByDisplayValue('0.1') },
{ controlType: 'useRegex checkbox', getValue: () => screen.getByLabelText('Use Regex') },
{ controlType: 'wholeWordSearch checkbox', getValue: () => screen.getByLabelText('Whole Word Search') },
{ controlType: 'convertPDFToImage checkbox', getValue: () => screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)') },
])('should disable $controlType when disabled prop is true', ({ getValue }) => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
const control = getValue();
expect(control).toBeDisabled();
});
test('should have correct padding input constraints', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// NumberInput in Mantine might not expose these attributes directly on the input element
// Instead, check that the NumberInput component is rendered with correct placeholder
const paddingInput = screen.getByPlaceholderText('0.1');
expect(paddingInput).toBeInTheDocument();
expect(paddingInput).toHaveDisplayValue('0.1');
});
});

View File

@ -0,0 +1,69 @@
import { Stack, NumberInput, ColorInput, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
interface RedactAdvancedSettingsProps {
parameters: RedactParameters;
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
disabled?: boolean;
}
const RedactAdvancedSettings = ({ parameters, onParameterChange, disabled = false }: RedactAdvancedSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Box Color */}
<ColorInput
label={t('redact.auto.colorLabel', 'Box Colour')}
value={parameters.redactColor}
onChange={(value) => onParameterChange('redactColor', value)}
disabled={disabled}
size="sm"
format="hex"
/>
{/* Box Padding */}
<NumberInput
label={t('redact.auto.customPaddingLabel', 'Custom Extra Padding')}
value={parameters.customPadding}
onChange={(value) => onParameterChange('customPadding', typeof value === 'number' ? value : 0.1)}
min={0}
max={10}
step={0.1}
disabled={disabled}
size="sm"
placeholder="0.1"
/>
{/* Use Regex */}
<Checkbox
label={t('redact.auto.useRegexLabel', 'Use Regex')}
checked={parameters.useRegex}
onChange={(e) => onParameterChange('useRegex', e.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
{/* Whole Word Search */}
<Checkbox
label={t('redact.auto.wholeWordSearchLabel', 'Whole Word Search')}
checked={parameters.wholeWordSearch}
onChange={(e) => onParameterChange('wholeWordSearch', e.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
{/* Convert PDF to PDF-Image */}
<Checkbox
label={t('redact.auto.convertPDFToImageLabel', 'Convert PDF to PDF-Image (Used to remove text behind the box)')}
checked={parameters.convertPDFToImage}
onChange={(e) => onParameterChange('convertPDFToImage', e.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
</Stack>
);
};
export default RedactAdvancedSettings;

View File

@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters';
import ButtonSelector from '../../shared/ButtonSelector';
interface RedactModeSelectorProps {
mode: RedactMode;
onModeChange: (mode: RedactMode) => void;
disabled?: boolean;
}
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
const { t } = useTranslation();
return (
<ButtonSelector
label={t('redact.modeSelector.mode', 'Mode')}
value={mode}
onChange={onModeChange}
options={[
{
value: 'automatic' as const,
label: t('redact.modeSelector.automatic', 'Automatic'),
},
{
value: 'manual' as const,
label: t('redact.modeSelector.manual', 'Manual'),
disabled: true, // Keep manual mode disabled until implemented
},
]}
disabled={disabled}
/>
);
}

View File

@ -0,0 +1,183 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import RedactSingleStepSettings from './RedactSingleStepSettings';
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
// Mock useTranslation
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('RedactSingleStepSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render mode selector', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('Mode')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument();
});
test('should render automatic mode settings when mode is automatic', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Default mode is automatic, so these should be visible
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
expect(screen.getByText('Box Colour')).toBeInTheDocument();
expect(screen.getByText('Use Regex')).toBeInTheDocument();
});
test('should render manual mode settings when mode is manual', () => {
const manualParameters = {
...defaultParameters,
mode: 'manual' as const,
};
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={manualParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Manual mode should show placeholder text
expect(screen.getByText('Manual redaction interface will be available here when implemented.')).toBeInTheDocument();
// Automatic mode settings should not be visible
expect(screen.queryByText('Words to Redact')).not.toBeInTheDocument();
});
test('should pass through parameter changes from automatic settings', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Test adding a word
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
fireEvent.click(addButton);
expect(mockOnParameterChange).toHaveBeenCalledWith('wordsToRedact', ['TestWord']);
});
test('should pass through parameter changes from advanced settings', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Test changing color
const colorInput = screen.getByDisplayValue('#000000');
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
});
test('should disable all controls when disabled prop is true', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
// Mode selector buttons should be disabled
expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled();
// Automatic settings controls should be disabled
expect(screen.getByPlaceholderText('Enter a word')).toBeDisabled();
expect(screen.getByRole('button', { name: '+ Add' })).toBeDisabled();
expect(screen.getByDisplayValue('#000000')).toBeDisabled();
});
test('should show current parameter values in automatic mode', () => {
const customParameters = {
...defaultParameters,
wordsToRedact: ['Word1', 'Word2'],
redactColor: '#FF0000',
useRegex: true,
customPadding: 0.5,
};
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that word tags are displayed
expect(screen.getByText('Word1')).toBeInTheDocument();
expect(screen.getByText('Word2')).toBeInTheDocument();
// Check that color is displayed
expect(screen.getByDisplayValue('#FF0000')).toBeInTheDocument();
// Check that regex checkbox is checked
const useRegexCheckbox = screen.getByLabelText('Use Regex');
expect(useRegexCheckbox).toBeChecked();
// Check that padding value is displayed
expect(screen.getByDisplayValue('0.5')).toBeInTheDocument();
});
test('should maintain consistent spacing and layout', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that the Stack container exists
const container = screen.getByText('Mode').closest('.mantine-Stack-root');
expect(container).toBeInTheDocument();
});
});

View File

@ -0,0 +1,61 @@
import { Stack, Divider } from "@mantine/core";
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
import RedactModeSelector from "./RedactModeSelector";
import WordsToRedactInput from "./WordsToRedactInput";
import RedactAdvancedSettings from "./RedactAdvancedSettings";
interface RedactSingleStepSettingsProps {
parameters: RedactParameters;
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
disabled?: boolean;
}
const RedactSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: RedactSingleStepSettingsProps) => {
return (
<Stack gap="md">
{/* Mode Selection */}
<RedactModeSelector
mode={parameters.mode}
onModeChange={(mode) => onParameterChange('mode', mode)}
disabled={disabled}
/>
{/* Automatic Mode Settings */}
{parameters.mode === 'automatic' && (
<>
<Divider />
{/* Words to Redact */}
<WordsToRedactInput
wordsToRedact={parameters.wordsToRedact}
onWordsChange={(words) => onParameterChange('wordsToRedact', words)}
disabled={disabled}
/>
<Divider />
{/* Advanced Settings */}
<RedactAdvancedSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* Manual Mode Placeholder */}
{parameters.mode === 'manual' && (
<>
<Divider />
<Stack gap="md">
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
Manual redaction interface will be available here when implemented.
</div>
</Stack>
</>
)}
</Stack>
);
};
export default RedactSingleStepSettings;

View File

@ -0,0 +1,191 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import WordsToRedactInput from './WordsToRedactInput';
// Mock useTranslation
const mockT = vi.fn((_key: string, fallback: string) => fallback);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('WordsToRedactInput', () => {
const mockOnWordsChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render with title and input field', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '+ Add' })).toBeInTheDocument();
});
test.each([
{ trigger: 'Add button click', action: (_input: HTMLElement, addButton: HTMLElement) => fireEvent.click(addButton) },
{ trigger: 'Enter key press', action: (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) },
])('should add word when $trigger', ({ action }) => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
action(input, addButton);
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
});
test('should not add empty word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.click(addButton);
expect(mockOnWordsChange).not.toHaveBeenCalled();
});
test('should not add duplicate word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Existing']}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'Existing' } });
fireEvent.click(addButton);
expect(mockOnWordsChange).not.toHaveBeenCalled();
});
test('should trim whitespace when adding word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: ' TestWord ' } });
fireEvent.click(addButton);
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
});
test('should remove word when x button is clicked', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Word1', 'Word2']}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const removeButtons = screen.getAllByText('×');
fireEvent.click(removeButtons[0]);
expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']);
});
test('should clear input after adding word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word') as HTMLInputElement;
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
fireEvent.click(addButton);
expect(input.value).toBe('');
});
test.each([
{ description: 'disable Add button when input is empty', inputValue: '', expectedDisabled: true },
{ description: 'enable Add button when input has text', inputValue: 'TestWord', expectedDisabled: false },
])('should $description', ({ inputValue, expectedDisabled }) => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: inputValue } });
expect(addButton).toHaveProperty('disabled', expectedDisabled);
});
test('should disable all controls when disabled prop is true', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Word1']}
onWordsChange={mockOnWordsChange}
disabled={true}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
const removeButton = screen.getByText('×');
expect(input).toBeDisabled();
expect(addButton).toBeDisabled();
expect(removeButton.closest('button')).toBeDisabled();
});
});

View File

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, TextInput, Button, Group, ActionIcon } from '@mantine/core';
interface WordsToRedactInputProps {
wordsToRedact: string[];
onWordsChange: (words: string[]) => void;
disabled?: boolean;
}
export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disabled }: WordsToRedactInputProps) {
const { t } = useTranslation();
const [currentWord, setCurrentWord] = useState('');
const addWord = () => {
if (currentWord.trim() && !wordsToRedact.includes(currentWord.trim())) {
onWordsChange([...wordsToRedact, currentWord.trim()]);
setCurrentWord('');
}
};
const removeWord = (index: number) => {
onWordsChange(wordsToRedact.filter((_, i) => i !== index));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addWord();
}
};
return (
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('redact.auto.wordsToRedact.title', 'Words to Redact')}
</Text>
{/* Current words */}
{wordsToRedact.map((word, index) => (
<Group key={index} justify="space-between" p="sm" style={{
borderRadius: 'var(--mantine-radius-sm)',
border: `1px solid var(--mantine-color-gray-3)`,
backgroundColor: 'var(--mantine-color-gray-0)'
}}>
<Text
size="sm"
style={{
maxWidth: '80%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={word}
>
{word}
</Text>
<ActionIcon
size="sm"
variant="subtle"
color="red"
onClick={() => removeWord(index)}
disabled={disabled}
>
×
</ActionIcon>
</Group>
))}
{/* Add new word input */}
<Group gap="sm" align="end">
<TextInput
placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')}
value={currentWord}
onChange={(e) => setCurrentWord(e.target.value)}
onKeyDown={handleKeyPress}
disabled={disabled}
size="sm"
style={{ flex: 1 }}
/>
<Button
size="sm"
variant="light"
onClick={addWord}
disabled={disabled || !currentWord.trim()}
>
+ {t('redact.auto.wordsToRedact.add', 'Add')}
</Button>
</Group>
{/* Examples */}
{wordsToRedact.length === 0 && (
<Text size="xs" c="dimmed">
{t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')}
</Text>
)}
</Stack>
);
}

View File

@ -4,7 +4,7 @@ import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/us
interface RemovePasswordSettingsProps {
parameters: RemovePasswordParameters;
onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void;
onParameterChange: <K extends keyof RemovePasswordParameters>(key: K, value: RemovePasswordParameters[K]) => void;
disabled?: boolean;
}

View File

@ -4,7 +4,7 @@ import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sani
interface SanitizeSettingsProps {
parameters: SanitizeParameters;
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void;
onParameterChange: <K extends keyof SanitizeParameters>(key: K, value: SanitizeParameters[K]) => void;
disabled?: boolean;
}

View File

@ -10,11 +10,12 @@ import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: StirlingFile[];
placeholder?: string;
minFiles?: number;
}
const FileStatusIndicator = ({
selectedFiles = [],
minFiles = 1,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
@ -55,6 +56,14 @@ const FileStatusIndicator = ({
return null;
}
const getPlaceholder = () => {
if (minFiles === undefined || minFiles === 1) {
return t("files.selectFromWorkbench", "Select files from the workbench or ");
} else {
return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles });
}
};
// Check if there are no files in the workbench
if (stirlingFileStubs.length === 0) {
// If no recent files, show upload button
@ -89,12 +98,12 @@ const FileStatusIndicator = ({
}
// Show selection status when there are files in workbench
if (selectedFiles.length === 0) {
if (selectedFiles.length < minFiles) {
// If no recent files, show upload option
if (!hasRecentFiles) {
return (
<Text size="sm" c="dimmed">
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={handleNativeUpload}
@ -109,7 +118,7 @@ const FileStatusIndicator = ({
// If there are recent files, show add files option
return (
<Text size="sm" c="dimmed">
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}

View File

@ -7,7 +7,7 @@ export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
placeholder?: string;
minFiles?: number;
}
export function createFilesToolStep(
@ -23,7 +23,7 @@ export function createFilesToolStep(
}, (
<FileStatusIndicator
selectedFiles={props.selectedFiles}
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
minFiles={props.minFiles}
/>
));
}

View File

@ -53,7 +53,7 @@ const renderTooltipTitle = (
<Text fw={400} size="sm">
{title}
</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>
</Tooltip>
);

View File

@ -22,7 +22,7 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT
<Text fw={500} size="lg" p="xs">
{title}
</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>
);

View File

@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
placeholder?: string;
minFiles?: number;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
placeholder: config.files.placeholder,
minFiles: config.files.minFiles,
onCollapsedClick: config.files.onCollapsedClick
})}

View File

@ -1,11 +1,11 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { isSplitMode, isSplitType, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
export interface SplitSettingsProps {
parameters: SplitParameters;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
disabled?: boolean;
}
@ -62,7 +62,7 @@ const SplitSettings = ({
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType}
onChange={(v) => v && onParameterChange('splitType', v)}
onChange={(v) => isSplitType(v) && onParameterChange('splitType', v)}
disabled={disabled}
data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },

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

@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useMergeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
tips: [
{
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.')
},
{
title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'),
description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.')
}
]
};
};

View File

@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useRedactModeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.mode.header.title", "Redaction Method")
},
tips: [
{
title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"),
description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.")
},
{
title: t("redact.tooltip.mode.manual.title", "Manual Redaction"),
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
}
]
};
};
export const useRedactWordsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.words.header.title", "Words to Redact")
},
tips: [
{
title: t("redact.tooltip.words.description.title", "Text Matching"),
description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."),
bullets: [
t("redact.tooltip.words.bullet1", "Add one word at a time"),
t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"),
t("redact.tooltip.words.bullet3", "Click × to remove words")
]
},
{
title: t("redact.tooltip.words.examples.title", "Common Examples"),
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
}
]
};
};
export const useRedactAdvancedTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
},
tips: [
{
title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."),
},
{
title: t("redact.tooltip.advanced.regex.title", "Use Regex"),
description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."),
bullets: [
t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"),
t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly")
]
},
{
title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"),
description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.")
},
{
title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"),
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
}
]
};
};

View File

@ -6,9 +6,11 @@ import CloseIcon from '@mui/icons-material/Close';
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { ViewerProvider, useViewer } from "../../contexts/ViewerContext";
import { LocalEmbedPDF } from './LocalEmbedPDF';
import { PdfViewerToolbar } from './PdfViewerToolbar';
import { ThumbnailSidebar } from './ThumbnailSidebar';
import '../../types/embedPdf';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@ -17,7 +19,7 @@ export interface EmbedPdfViewerProps {
previewFile?: File | null;
}
const EmbedPdfViewer = ({
const EmbedPdfViewerContent = ({
sidebarsVisible,
setSidebarsVisible,
onClose,
@ -28,7 +30,7 @@ const EmbedPdfViewer = ({
const { colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar } = useViewer();
// Get current file from FileContext
const { selectors } = useFileState();
@ -68,7 +70,7 @@ const EmbedPdfViewer = ({
event.preventDefault();
event.stopPropagation();
const zoomAPI = (window as any).embedPdfZoom;
const zoomAPI = window.embedPdfZoom;
if (zoomAPI) {
if (event.deltaY < 0) {
// Scroll up - zoom in
@ -97,7 +99,7 @@ const EmbedPdfViewer = ({
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
const zoomAPI = (window as any).embedPdfZoom;
const zoomAPI = window.embedPdfZoom;
if (zoomAPI) {
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
@ -120,14 +122,12 @@ const EmbedPdfViewer = ({
// Expose toggle functions globally for right rail buttons
React.useEffect(() => {
(window as any).toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
window.toggleThumbnailSidebar = toggleThumbnailSidebar;
return () => {
delete (window as any).toggleThumbnailSidebar;
delete window.toggleThumbnailSidebar;
};
}, []);
}, [toggleThumbnailSidebar]);
return (
<Box
@ -212,7 +212,7 @@ const EmbedPdfViewer = ({
}}
dualPage={false}
onDualPageToggle={() => {
(window as any).embedPdfSpread?.toggleSpreadMode();
window.embedPdfSpread?.toggleSpreadMode();
}}
currentZoom={100}
/>
@ -224,11 +224,19 @@ const EmbedPdfViewer = ({
{/* Thumbnail Sidebar */}
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={() => setIsThumbnailSidebarVisible(prev => !prev)}
onToggle={toggleThumbnailSidebar}
colorScheme={colorScheme}
/>
</Box>
);
};
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
return (
<ViewerProvider>
<EmbedPdfViewerContent {...props} />
</ViewerProvider>
);
};
export default EmbedPdfViewer;

View File

@ -1,687 +0,0 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import LastPageIcon from "@mui/icons-material/LastPage";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import CloseIcon from "@mui/icons-material/Close";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file";
// Lazy loading page image component
interface LazyPageImageProps {
pageIndex: number;
zoom: number;
theme: any;
isFirst: boolean;
renderPage: (pageIndex: number) => Promise<string | null>;
pageImages: (string | null)[];
setPageRef: (index: number, ref: HTMLImageElement | null) => void;
}
const LazyPageImage = ({
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
}: LazyPageImageProps) => {
const [isVisible, setIsVisible] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !imageUrl) {
setIsVisible(true);
}
});
},
{
rootMargin: '200px', // Start loading 200px before visible
threshold: 0.1
}
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [imageUrl]);
// Update local state when pageImages changes (from preloading)
useEffect(() => {
if (pageImages[pageIndex]) {
setImageUrl(pageImages[pageIndex]);
}
}, [pageImages, pageIndex]);
useEffect(() => {
if (isVisible && !imageUrl) {
renderPage(pageIndex).then((url) => {
if (url) setImageUrl(url);
});
}
}, [isVisible, imageUrl, pageIndex, renderPage]);
useEffect(() => {
if (imgRef.current) {
setPageRef(pageIndex, imgRef.current);
}
}, [pageIndex, setPageRef]);
if (imageUrl) {
return (
<img
ref={imgRef}
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
}}
/>
);
}
// Placeholder while loading
return (
<div
ref={imgRef}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
height: 800 * zoom, // Estimated height
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
border: '1px dashed #ccc'
}}
>
{isVisible ? (
<div style={{ textAlign: 'center' }}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 8px'
}} />
<Text size="sm" c="dimmed">Loading page {pageIndex + 1}...</Text>
</div>
) : (
<Text size="sm" c="dimmed">Page {pageIndex + 1}</Text>
)}
</div>
);
};
export interface ViewerProps {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile: File | null; // For preview mode - bypasses context
}
const Viewer = ({
onClose,
previewFile,
}: ViewerProps) => {
const { t } = useTranslation();
const theme = useMantineTheme();
// Get current file from FileContext
const { selectors } = useFileState();
const activeFiles = selectors.getFiles();
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
// Reset PDF state when switching tabs
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
setNumPages(0);
setPageImages([]);
setCurrentPage(null);
setLoading(true);
};
const [numPages, setNumPages] = useState<number>(0);
const [pageImages, setPageImages] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number | null>(null);
const [dualPage, setDualPage] = useState(false);
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Memoize setPageRef to prevent infinite re-renders
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
pageRefs.current[index] = ref;
}, []);
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]);
const file1WithUrl = useFileWithUrl(activeFiles[1]);
const file2WithUrl = useFileWithUrl(activeFiles[2]);
const file3WithUrl = useFileWithUrl(activeFiles[3]);
const file4WithUrl = useFileWithUrl(activeFiles[4]);
const filesWithUrls = React.useMemo(() => {
return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl]
.slice(0, activeFiles.length)
.filter(Boolean);
}, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]);
// Use preview file if available, otherwise use active tab file
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Validate the preview file
if (!isFileObject(previewFile)) {
return null;
}
if (previewFile.size === 0) {
return null;
}
return { file: previewFile, url: null };
} else {
// Use the file from the active tab
const tabIndex = parseInt(activeTab);
return filesWithUrls[tabIndex] || null;
}
}, [previewFile, filesWithUrls, activeTab]);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const pdfDocRef = useRef<any>(null);
const renderingPagesRef = useRef<Set<number>>(new Set());
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
const preloadingRef = useRef<boolean>(false);
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
return null;
}
const pageNum = pageIndex + 1;
if (pageImages[pageIndex]) {
return pageImages[pageIndex]; // Already rendered
}
renderingPagesRef.current.add(pageIndex);
try {
const page = await pdfDocRef.current.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.2 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx, viewport }).promise;
const dataUrl = canvas.toDataURL();
// Update the pageImages array
setPageImages(prev => {
const newImages = [...prev];
newImages[pageIndex] = dataUrl;
return newImages;
});
renderingPagesRef.current.delete(pageIndex);
return dataUrl;
}
} catch (error) {
console.error(`Failed to render page ${pageNum}:`, error);
}
renderingPagesRef.current.delete(pageIndex);
return null;
};
// Progressive preloading function
const startProgressivePreload = async () => {
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
preloadingRef.current = true;
// Start with first few pages for immediate viewing
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
// Render priority pages first
for (const pageIndex of priorityPages) {
if (pageIndex < numPages && !pageImages[pageIndex]) {
await renderPage(pageIndex);
// Small delay to allow UI to update
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Then render remaining pages in background
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
if (!pageImages[pageIndex]) {
await renderPage(pageIndex);
// Longer delay for background loading to not block UI
await new Promise(resolve => setTimeout(resolve, 100));
}
}
preloadingRef.current = false;
};
// Initialize current page when PDF loads
useEffect(() => {
if (numPages > 0 && !currentPage) {
setCurrentPage(1);
}
}, [numPages, currentPage]);
// Function to scroll to a specific page
const scrollToPage = (pageNumber: number) => {
const el = pageRefs.current[pageNumber - 1];
const scrollArea = scrollAreaRef.current;
if (el && scrollArea) {
const scrollAreaRect = scrollArea.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const currentScrollTop = scrollArea.scrollTop;
// Position page near top of viewport with some padding
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
scrollArea.scrollTo({
top: targetScrollTop,
behavior: "smooth"
});
}
};
// Throttled scroll handler to prevent jerky updates
const handleScrollThrottled = useCallback(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea || !pageRefs.current.length) return;
const areaRect = scrollArea.getBoundingClientRect();
const viewportCenter = areaRect.top + areaRect.height / 2;
let closestIdx = 0;
let minDist = Infinity;
pageRefs.current.forEach((img, idx) => {
if (img) {
const imgRect = img.getBoundingClientRect();
const imgCenter = imgRect.top + imgRect.height / 2;
const dist = Math.abs(imgCenter - viewportCenter);
if (dist < minDist) {
minDist = dist;
closestIdx = idx;
}
}
});
// Update page number display only if changed
if (currentPage !== closestIdx + 1) {
setCurrentPage(closestIdx + 1);
}
}, [currentPage]);
// Throttle scroll events to reduce jerkiness
const handleScroll = useCallback(() => {
if (window.requestAnimationFrame) {
window.requestAnimationFrame(handleScrollThrottled);
} else {
handleScrollThrottled();
}
}, [handleScrollThrottled]);
useEffect(() => {
let cancelled = false;
async function loadPdfInfo() {
if (!effectiveFile) {
setNumPages(0);
setPageImages([]);
return;
}
setLoading(true);
try {
let pdfData;
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if (previewFile && effectiveFile.file === previewFile) {
const arrayBuffer = await previewFile.arrayBuffer();
pdfData = { data: arrayBuffer };
}
// Handle special IndexedDB URLs for large files
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
if (!arrayBuffer) {
throw new Error('File not found in IndexedDB - may have been purged by browser');
}
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;
pdfData = { data: arrayBuffer };
} else if (effectiveFile.url) {
// Standard blob URL or regular URL
pdfData = effectiveFile.url;
} else {
throw new Error('No valid PDF source available');
}
const pdf = await pdfWorkerManager.createDocument(pdfData);
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
setPageImages(new Array(pdf.numPages).fill(null));
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
} catch {
if (!cancelled) {
setPageImages([]);
setNumPages(0);
}
}
if (!cancelled) setLoading(false);
}
loadPdfInfo();
return () => {
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
// Cleanup PDF document using worker manager
if (pdfDocRef.current) {
pdfWorkerManager.destroyDocument(pdfDocRef.current);
pdfDocRef.current = null;
}
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
}, [effectiveFile, previewFile]);
useEffect(() => {
const viewport = scrollAreaRef.current;
if (!viewport) return;
const handler = () => {
handleScroll();
};
viewport.addEventListener("scroll", handler);
return () => viewport.removeEventListener("scroll", handler);
}, [pageImages]);
return (
<Box style={{ position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
<ActionIcon
variant="filled"
color="gray"
size="lg"
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
borderRadius: '50%',
}}
onClick={onClose}
>
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box
style={{
borderBottom: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
position: 'relative',
zIndex: 100,
marginTop: '60px' // Push tabs below TopControls
}}
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file: any, index: number) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
</Box>
)}
{loading ? (
<div style={{ flex: 1, padding: '1rem' }}>
<SkeletonLoader type="viewer" />
</div>
) : (
<ScrollArea
style={{ flex: 1, position: "relative"}}
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
{numPages === 0 && (
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
)}
{dualPage
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
<LazyPageImage
pageIndex={i * 2}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
{i * 2 + 1 < numPages && (
<LazyPageImage
pageIndex={i * 2 + 1}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
)}
</Group>
))
: Array.from({ length: numPages }).map((_, idx) => (
<LazyPageImage
key={idx}
pageIndex={idx}
zoom={zoom}
theme={theme}
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
))}
</Stack>
{/* Navigation bar overlays the scroll area */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<Paper
radius="xl xl 0 0"
shadow="sm"
p={12}
pb={12}
style={{
display: "flex",
alignItems: "center",
gap: 10,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
minWidth: 420,
maxWidth: 700,
flexWrap: "wrap",
}}
>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
scrollToPage(1);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<FirstPageIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
const prevPage = Math.max(1, (currentPage || 1) - 1);
scrollToPage(prevPage);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<ArrowBackIosNewIcon fontSize="small" />
</Button>
<NumberInput
value={currentPage || 1}
onChange={value => {
const page = Number(value);
if (!isNaN(page) && page >= 1 && page <= numPages) {
scrollToPage(page);
}
}}
min={1}
max={numPages}
hideControls
styles={{
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16},
}}
/>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {numPages}
</span>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
scrollToPage(nextPage);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
scrollToPage(numPages);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<LastPageIcon fontSize="small" />
</Button>
<Button
variant={dualPage ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={() => setDualPage(v => !v)}
style={{ minWidth: 36 }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.max(0.1, z - 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomOut", "Zoom out")}
></Button>
<span style={{ minWidth: 40, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.min(5, z + 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomIn", "Zoom in")}
>+</Button>
</Group>
</Paper>
</div>
</ScrollArea>
)}
</>
)}
</Box>
);
};
export default Viewer;

View File

@ -7,6 +7,7 @@ import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import LastPageIcon from '@mui/icons-material/LastPage';
import DescriptionIcon from '@mui/icons-material/Description';
import ViewWeekIcon from '@mui/icons-material/ViewWeek';
import '../../types/embedPdf';
interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now)
@ -41,23 +42,23 @@ export function PdfViewerToolbar({
useEffect(() => {
const updateState = () => {
// Update zoom
if ((window as any).embedPdfZoom) {
const zoomPercent = (window as any).embedPdfZoom.zoomPercent || currentZoom;
if (window.embedPdfZoom) {
const zoomPercent = window.embedPdfZoom.zoomPercent || currentZoom;
setDynamicZoom(zoomPercent);
}
// Update scroll/page state
if ((window as any).embedPdfScroll) {
const currentPageNum = (window as any).embedPdfScroll.currentPage || currentPage;
const totalPagesNum = (window as any).embedPdfScroll.totalPages || totalPages;
if (window.embedPdfScroll) {
const currentPageNum = window.embedPdfScroll.currentPage || currentPage;
const totalPagesNum = window.embedPdfScroll.totalPages || totalPages;
setDynamicPage(currentPageNum);
setDynamicTotalPages(totalPagesNum);
setPageInput(currentPageNum);
}
// Update pan mode state
if ((window as any).embedPdfPan) {
const panState = (window as any).embedPdfPan.isPanning || false;
if (window.embedPdfPan) {
const panState = window.embedPdfPan.isPanning || false;
setIsPanning(panState);
}
};
@ -72,20 +73,20 @@ export function PdfViewerToolbar({
}, [currentZoom, currentPage, totalPages]);
const handleZoomOut = () => {
if ((window as any).embedPdfZoom) {
(window as any).embedPdfZoom.zoomOut();
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomOut();
}
};
const handleZoomIn = () => {
if ((window as any).embedPdfZoom) {
(window as any).embedPdfZoom.zoomIn();
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomIn();
}
};
const handlePageNavigation = (page: number) => {
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToPage(page);
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPage(page);
} else if (onPageChange) {
onPageChange(page);
}
@ -93,32 +94,32 @@ export function PdfViewerToolbar({
};
const handleFirstPage = () => {
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToFirstPage();
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToFirstPage();
} else {
handlePageNavigation(1);
}
};
const handlePreviousPage = () => {
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToPreviousPage();
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPreviousPage();
} else {
handlePageNavigation(Math.max(1, dynamicPage - 1));
}
};
const handleNextPage = () => {
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToNextPage();
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToNextPage();
} else {
handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1));
}
};
const handleLastPage = () => {
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToLastPage();
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToLastPage();
} else {
handlePageNavigation(dynamicTotalPages);
}

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Box, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { LocalIcon } from '../shared/LocalIcon';
import '../../types/embedPdf';
interface ThumbnailSidebarProps {
visible: boolean;
@ -18,7 +19,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
// Get total pages from scroll API
useEffect(() => {
const scrollAPI = (window as any).embedPdfScroll;
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.totalPages) {
setTotalPages(scrollAPI.totalPages);
}
@ -28,7 +29,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
useEffect(() => {
if (!visible || totalPages === 0) return;
const thumbnailAPI = (window as any).embedPdfThumbnail?.thumbnailAPI;
const thumbnailAPI = window.embedPdfThumbnail?.thumbnailAPI;
console.log('📄 ThumbnailSidebar useEffect triggered:', {
visible,
thumbnailAPI: !!thumbnailAPI,
@ -88,14 +89,14 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
}
});
};
}, [visible, totalPages, thumbnails]);
}, [visible, totalPages]);
const handlePageClick = (pageIndex: number) => {
const pageNumber = pageIndex + 1; // Convert to 1-based
setSelectedPage(pageNumber);
// Use scroll API to navigate to page
const scrollAPI = (window as any).embedPdfScroll;
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
scrollAPI.scrollToPage(pageNumber);
}

View File

@ -0,0 +1,46 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ViewerContextType {
// Thumbnail sidebar state
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
setThumbnailSidebarVisible: (visible: boolean) => void;
}
const ViewerContext = createContext<ViewerContextType | null>(null);
interface ViewerProviderProps {
children: ReactNode;
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const setThumbnailSidebarVisible = (visible: boolean) => {
setIsThumbnailSidebarVisible(visible);
};
const value: ViewerContextType = {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
setThumbnailSidebarVisible,
};
return (
<ViewerContext.Provider value={value}>
{children}
</ViewerContext.Provider>
);
};
export const useViewer = (): ViewerContextType => {
const context = useContext(ViewerContext);
if (!context) {
throw new Error('useViewer must be used within a ViewerProvider');
}
return context;
};

View File

@ -145,12 +145,17 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
// Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
// Reorder selected files by passed order
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
return {
...state,
files: {
...state.files,
ids: validIds
},
ui: {
...state.ui,
selectedFileIds,
}
};
}
@ -234,11 +239,14 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
case 'CONSUME_FILES': {
const { inputFileIds, outputStirlingFileStubs } = action.payload;
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
}
case 'UNDO_CONSUME_FILES': {
const { inputStirlingFileStubs, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
}

View File

@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds
selectedFiles: selectors.getSelectedFiles(),
selectedRecords: selectors.getSelectedStirlingFileStubs(),
selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
@ -169,7 +169,6 @@ export function useFileContext() {
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
// File ID lookup
findFileId: (file: File) => {
return state.files.ids.find(id => {

View File

@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AddWatermark from "../tools/AddWatermark";
import Merge from '../tools/Merge';
import Repair from "../tools/Repair";
import AutoRename from "../tools/AutoRename";
import SingleLargePage from "../tools/SingleLargePage";
@ -30,8 +31,10 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@ -44,7 +47,13 @@ import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import { ToolId } from "../types/toolId";
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
@ -331,11 +340,14 @@ export function useFlatToolRegistry(): ToolRegistry {
"adjust-page-size-scale": {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
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."),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
settingsComponent: AdjustPageScaleSettings,
},
addPageNumbers: {
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
@ -669,12 +681,14 @@ export function useFlatToolRegistry(): ToolRegistry {
mergePdfs: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
component: null,
component: Merge,
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings
},
"multi-tool": {
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
@ -701,10 +715,14 @@ export function useFlatToolRegistry(): ToolRegistry {
redact: {
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.redact.title", "Redact"),
component: null,
component: Redact,
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
settingsComponent: RedactSingleStepSettings,
},
};

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,138 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMergeOperation } from './useMergeOperation';
import type { MergeParameters } from './useMergeParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation');
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
describe('useMergeOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: function (): Promise<void> {
throw new Error('Function not implemented.');
}
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test('should build FormData correctly', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
];
const parameters: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: false
};
const formData = config.buildFormData(parameters, mockFiles);
// Verify files are appended
expect(formData.getAll('fileInput')).toHaveLength(2);
expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
// Verify parameters are appended correctly
expect(formData.get('sortType')).toBe('orderProvided');
expect(formData.get('removeCertSign')).toBe('true');
expect(formData.get('generateToc')).toBe('false');
});
test('should handle response correctly', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
const mockFiles = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
];
const result = config.responseHandler!(mockBlob, mockFiles) as File[];
expect(result).toHaveLength(1);
expect(result[0].name).toBe('merged_file1.pdf');
expect(result[0].type).toBe('application/pdf');
expect(result[0].size).toBe(mockBlob.size);
});
test('should return the hook result from useToolOperation', () => {
const { result } = renderHook(() => useMergeOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test('should use correct translation keys for error handling', () => {
renderHook(() => useMergeOperation());
expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
});
test('should build FormData with different parameter combinations', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
// Test case 1: All options disabled
const params1: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false
};
const formData1 = config.buildFormData(params1, mockFiles);
expect(formData1.get('removeCertSign')).toBe('false');
expect(formData1.get('generateToc')).toBe('false');
// Test case 2: All options enabled
const params2: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: true
};
const formData2 = config.buildFormData(params2, mockFiles);
expect(formData2.get('removeCertSign')).toBe('true');
expect(formData2.get('generateToc')).toBe('true');
});
});

View File

@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { MergeParameters } from './useMergeParameters';
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
const formData = new FormData();
files.forEach((file) => {
formData.append("fileInput", file);
});
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
formData.append("generateToc", parameters.generateTableOfContents.toString());
return formData;
};
const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => {
const filename = `merged_${originalFiles[0].name}`
return [new File([blob], filename, { type: 'application/pdf' })];
};
// Operation configuration for automation
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
toolType: ToolType.multiFile,
buildFormData,
operationType: 'merge',
endpoint: '/api/v1/general/merge-pdfs',
filePrefix: 'merged_',
responseHandler: mergeResponseHandler,
};
export const useMergeOperation = () => {
const { t } = useTranslation();
return useToolOperation<MergeParameters>({
...mergeOperationConfig,
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
});
};

View File

@ -0,0 +1,68 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMergeParameters, defaultParameters } from './useMergeParameters';
describe('useMergeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useMergeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'removeDigitalSignature' as const, value: true },
{ paramName: 'removeDigitalSignature' as const, value: false },
{ paramName: 'generateTableOfContents' as const, value: true },
{ paramName: 'generateTableOfContents' as const, value: false }
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useMergeParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useMergeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('removeDigitalSignature', true);
result.current.updateParameter('generateTableOfContents', true);
});
expect(result.current.parameters.removeDigitalSignature).toBe(true);
expect(result.current.parameters.generateTableOfContents).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should validate parameters correctly - always returns true', () => {
const { result } = renderHook(() => useMergeParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Change parameters and validate again
act(() => {
result.current.updateParameter('removeDigitalSignature', true);
result.current.updateParameter('generateTableOfContents', true);
});
expect(result.current.validateParameters()).toBe(true);
// Reset and validate again
act(() => {
result.current.resetParameters();
});
expect(result.current.validateParameters()).toBe(true);
});
});

View File

@ -0,0 +1,21 @@
import { BaseParameters } from '../../../types/parameters';
import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
export interface MergeParameters extends BaseParameters {
removeDigitalSignature: boolean;
generateTableOfContents: boolean;
};
export const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
export const useMergeParameters = (): MergeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "merge-pdfs",
});
};

View File

@ -0,0 +1,142 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { buildRedactFormData, redactOperationConfig, useRedactOperation } from './useRedactOperation';
import { defaultParameters, RedactParameters } from './useRedactParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Mock the error handler utility
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => vi.fn())
}));
describe('buildRedactFormData', () => {
const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
test('should build form data for automatic mode', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Confidential', 'Secret'],
useRegex: true,
wholeWordSearch: true,
redactColor: '#FF0000',
customPadding: 0.5,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('fileInput')).toBe(mockFile);
expect(formData.get('listOfText')).toBe('Confidential\nSecret');
expect(formData.get('useRegex')).toBe('true');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed
expect(formData.get('customPadding')).toBe('0.5');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should handle empty words array', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: [],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('');
});
test('should join multiple words with newlines', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Word1', 'Word2', 'Word3'],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3');
});
test.each([
{ description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' },
{ description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' },
])('should $description', ({ redactColor, expected }) => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
redactColor,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('redactColor')).toBe(expected);
});
test('should convert boolean parameters to strings', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
useRegex: false,
wholeWordSearch: true,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('useRegex')).toBe('false');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should throw error for manual mode (not implemented)', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
});
});
describe('useRedactOperation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('should call useToolOperation with correct configuration', async () => {
const { useToolOperation } = await import('../shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
...redactOperationConfig,
getErrorMessage: expect.any(Function),
});
});
test('should provide error handler to useToolOperation', async () => {
const { useToolOperation } = await import('../shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
const callArgs = mockUseToolOperation.mock.calls[0][0];
expect(typeof callArgs.getErrorMessage).toBe('function');
});
});

View File

@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RedactParameters, defaultParameters } from './useRedactParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.mode === 'automatic') {
// Convert array to newline-separated string as expected by backend
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
formData.append("useRegex", parameters.useRegex.toString());
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
formData.append("redactColor", parameters.redactColor.replace('#', ''));
formData.append("customPadding", parameters.customPadding.toString());
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
} else {
// Manual mode parameters would go here when implemented
throw new Error('Manual redaction not yet implemented');
}
return formData;
};
// Static configuration object
export const redactOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRedactFormData,
operationType: 'redact',
endpoint: (parameters: RedactParameters) => {
if (parameters.mode === 'automatic') {
return '/api/v1/security/auto-redact';
} else {
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
}
},
filePrefix: 'redacted_',
defaultParameters,
} as const;
export const useRedactOperation = () => {
const { t } = useTranslation();
return useToolOperation<RedactParameters>({
...redactOperationConfig,
getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.'))
});
};

View File

@ -0,0 +1,134 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from './useRedactParameters';
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRedactParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'mode' as const, value: 'manual' as const },
{ paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] },
{ paramName: 'useRegex' as const, value: true },
{ paramName: 'wholeWordSearch' as const, value: true },
{ paramName: 'redactColor' as const, value: '#FF0000' },
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toStrictEqual(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRedactParameters());
// Modify some parameters
act(() => {
result.current.updateParameter('mode', 'manual');
result.current.updateParameter('wordsToRedact', ['test']);
result.current.updateParameter('useRegex', true);
});
// Reset parameters
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe('validation', () => {
test.each([
{ description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true },
{ description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false },
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
result.current.updateParameter('wordsToRedact', wordsToRedact);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
});
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
});
});
test('should maintain parameter state across updates', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
result.current.updateParameter('customPadding', 0.5);
result.current.updateParameter('wordsToRedact', ['word1']);
});
// All parameters should be updated
expect(result.current.parameters.redactColor).toBe('#FF0000');
expect(result.current.parameters.customPadding).toBe(0.5);
expect(result.current.parameters.wordsToRedact).toEqual(['word1']);
// Other parameters should remain at defaults
expect(result.current.parameters.mode).toBe('automatic');
expect(result.current.parameters.useRegex).toBe(false);
expect(result.current.parameters.wholeWordSearch).toBe(false);
expect(result.current.parameters.convertPDFToImage).toBe(true);
});
test('should handle array parameter updates correctly', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['initial']);
act(() => {
result.current.updateParameter('wordsToRedact', ['updated', 'multiple']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']);
});
});

View File

@ -0,0 +1,48 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export type RedactMode = 'automatic' | 'manual';
export interface RedactParameters extends BaseParameters {
mode: RedactMode;
// Automatic redaction parameters
wordsToRedact: string[];
useRegex: boolean;
wholeWordSearch: boolean;
redactColor: string;
customPadding: number;
convertPDFToImage: boolean;
}
export const defaultParameters: RedactParameters = {
mode: 'automatic',
wordsToRedact: [],
useRegex: false,
wholeWordSearch: false,
redactColor: '#000000',
customPadding: 0.1,
convertPDFToImage: true,
};
export type RedactParametersHook = BaseParametersHook<RedactParameters>;
export const useRedactParameters = (): RedactParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: (params) => {
if (params.mode === 'automatic') {
return '/api/v1/security/auto-redact';
}
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
},
validateFn: (params) => {
if (params.mode === 'automatic') {
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
}
// Manual mode validation would go here when implemented
return false;
}
});
};

View File

@ -6,12 +6,12 @@ import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters';
import { StirlingFile } from '../../../types/fileContext';
interface BaseToolReturn<TParams> {
interface BaseToolReturn<TParams, TParamsHook extends BaseParametersHook<TParams>> {
// File management
selectedFiles: StirlingFile[];
// Tool-specific hooks
params: BaseParametersHook<TParams>;
params: TParamsHook;
operation: ToolOperationHook<TParams>;
// Endpoint validation
@ -33,12 +33,14 @@ interface BaseToolReturn<TParams> {
/**
* Base tool hook for tool components. Manages standard behaviour for tools.
*/
export function useBaseTool<TParams>(
export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TParams>>(
toolName: string,
useParams: () => BaseParametersHook<TParams>,
useParams: () => TParamsHook,
useOperation: () => ToolOperationHook<TParams>,
props: BaseToolProps,
): BaseToolReturn<TParams> {
options?: { minFiles?: number }
): BaseToolReturn<TParams, TParamsHook> {
const minFiles = options?.minFiles ?? 1;
const { onPreviewFile, onComplete, onError } = props;
// File selection
@ -96,7 +98,7 @@ export function useBaseTool<TParams>(
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length > 0;
const hasFiles = selectedFiles.length >= minFiles;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;

View File

@ -59,6 +59,7 @@ i18n
.init({
fallbackLng: 'en-GB',
supportedLngs: Object.keys(supportedLanguages),
load: 'currentOnly',
nonExplicitSupportedLngs: false,
debug: process.env.NODE_ENV === 'development',

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;

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
@ -161,25 +161,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
content
});
// Dynamic file placeholder based on supported types
const filesPlaceholder = useMemo(() => {
if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
const firstOperation = stepData.automation.operations[0];
const toolConfig = toolRegistry[firstOperation.operation as keyof typeof toolRegistry];
// Check if the tool has supportedFormats that include non-PDF formats
if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
}
}
return t('automate.files.placeholder', 'Select PDF files to process with this automation');
}, [currentStep, stepData.automation, toolRegistry, t]);
// Always create files step to avoid conditional hook calls
const filesStep = createFilesToolStep(createStep, {
selectedFiles,
isCollapsed: hasResults,
placeholder: filesPlaceholder
});
const automationSteps = [

View File

@ -100,7 +100,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
files: {
selectedFiles,
isCollapsed: hasResults,
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
},
steps: [
{

View File

@ -22,7 +22,6 @@ const Flatten = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{

View File

@ -0,0 +1,98 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import MergeSettings from "../components/tools/merge/MergeSettings";
import MergeFileSorter from "../components/tools/merge/MergeFileSorter";
import { useMergeParameters } from "../hooks/tools/merge/useMergeParameters";
import { useMergeOperation } from "../hooks/tools/merge/useMergeOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useMergeTips } from "../components/tooltips/useMergeTips";
import { useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext";
const Merge = (props: BaseToolProps) => {
const { t } = useTranslation();
const mergeTips = useMergeTips();
// File selection hooks for custom sorting
const { fileIds } = useAllFiles();
const { selectedRecords } = useSelectedFiles();
const { reorderFiles } = useFileManagement();
const base = useBaseTool(
'merge',
useMergeParameters,
useMergeOperation,
props,
{ minFiles: 2 }
);
// Custom file sorting logic for merge tool
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
let comparison = 0;
switch (sortType) {
case 'filename':
comparison = recordA.name.localeCompare(recordB.name);
break;
case 'dateModified':
comparison = recordA.lastModified - recordB.lastModified;
break;
}
return ascending ? comparison : -comparison;
});
const selectedIds = sortedRecords.map(record => record.id);
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
reorderFiles([...selectedIds, ...deselectedIds]);
}, [selectedRecords, fileIds, reorderFiles]);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
minFiles: 2,
},
steps: [
{
title: "Sort Files",
isCollapsed: base.settingsCollapsed,
content: (
<MergeFileSorter
onSortFiles={sortFiles}
disabled={!base.hasFiles || base.endpointLoading}
/>
),
},
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: mergeTips,
content: (
<MergeSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("merge.submit", "Merge PDFs"),
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("merge.title", "Merge Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default Merge as ToolComponent;

View File

@ -0,0 +1,120 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import RedactModeSelector from "../components/tools/redact/RedactModeSelector";
import { useRedactParameters } from "../hooks/tools/redact/useRedactParameters";
import { useRedactOperation } from "../hooks/tools/redact/useRedactOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "../components/tooltips/useRedactTips";
import RedactAdvancedSettings from "../components/tools/redact/RedactAdvancedSettings";
import WordsToRedactInput from "../components/tools/redact/WordsToRedactInput";
const Redact = (props: BaseToolProps) => {
const { t } = useTranslation();
// State for managing step collapse status
const [methodCollapsed, setMethodCollapsed] = useState(false);
const [wordsCollapsed, setWordsCollapsed] = useState(false);
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const base = useBaseTool(
'redact',
useRedactParameters,
useRedactOperation,
props
);
// Tooltips for each step
const modeTips = useRedactModeTips();
const wordsTips = useRedactWordsTips();
const advancedTips = useRedactAdvancedTips();
const isExecuteDisabled = () => {
if (base.params.parameters.mode === 'manual') {
return true; // Manual mode not implemented yet
}
return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled;
};
// Compute actual collapsed state based on results and user state
const getActualCollapsedState = (userCollapsed: boolean) => {
return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown
};
// Build conditional steps based on redaction mode
const buildSteps = () => {
const steps = [
// Method selection step (always present)
{
title: t("redact.modeSelector.title", "Redaction Method"),
isCollapsed: getActualCollapsedState(methodCollapsed),
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
tooltip: modeTips,
content: (
<RedactModeSelector
mode={base.params.parameters.mode}
onModeChange={(mode) => base.params.updateParameter('mode', mode)}
disabled={base.endpointLoading}
/>
),
}
];
// Add mode-specific steps
if (base.params.parameters.mode === 'automatic') {
steps.push(
{
title: t("redact.auto.settings.title", "Redaction Settings"),
isCollapsed: getActualCollapsedState(wordsCollapsed),
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setWordsCollapsed(!wordsCollapsed),
tooltip: wordsTips,
content: <WordsToRedactInput
wordsToRedact={base.params.parameters.wordsToRedact}
onWordsChange={(words) => base.params.updateParameter('wordsToRedact', words)}
disabled={base.endpointLoading}
/>,
},
{
title: t("redact.auto.settings.advancedTitle", "Advanced Settings"),
isCollapsed: getActualCollapsedState(advancedCollapsed),
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setAdvancedCollapsed(!advancedCollapsed),
tooltip: advancedTips,
content: <RedactAdvancedSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>,
},
);
} else if (base.params.parameters.mode === 'manual') {
// Manual mode steps would go here when implemented
}
return steps;
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: buildSteps(),
executeButton: {
text: t("redact.submit", "Redact"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: isExecuteDisabled(),
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("redact.title", "Redaction Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default Redact as ToolComponent;

View File

@ -19,7 +19,6 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {

View File

@ -19,7 +19,6 @@ const Repair = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {

View File

@ -20,7 +20,6 @@ const Sanitize = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{

View File

@ -19,7 +19,6 @@ const SingleLargePage = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {

View File

@ -19,7 +19,6 @@ const UnlockPdfForms = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasFiles || base.hasResults,
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {

View File

@ -0,0 +1,43 @@
// Types for EmbedPDF global APIs
export interface EmbedPdfZoomAPI {
zoomPercent: number;
zoomIn: () => void;
zoomOut: () => void;
}
export interface EmbedPdfScrollAPI {
currentPage: number;
totalPages: number;
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
}
export interface EmbedPdfPanAPI {
isPanning: boolean;
}
export interface EmbedPdfSpreadAPI {
toggleSpreadMode: () => void;
}
export interface EmbedPdfThumbnailAPI {
thumbnailAPI: {
renderThumb: (pageIndex: number, scale: number) => {
toPromise: () => Promise<Blob>;
};
};
}
declare global {
interface Window {
embedPdfZoom?: EmbedPdfZoomAPI;
embedPdfScroll?: EmbedPdfScrollAPI;
embedPdfPan?: EmbedPdfPanAPI;
embedPdfSpread?: EmbedPdfSpreadAPI;
embedPdfThumbnail?: EmbedPdfThumbnailAPI;
toggleThumbnailSidebar?: () => void;
}
}

View File

@ -31,7 +31,6 @@
"moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
// "@framework": ["vendor/embed-pdf-viewer/packages/core/src/react/adapter.ts"]
},
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */

74
testing/test_pdf_1.pdf Normal file
View File

@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
>>
stream
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp2V9^`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngc-TW~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000526 00000 n
0000000594 00000 n
0000000890 00000 n
0000000949 00000 n
trailer
<<
/ID
[<cb35d644a26f0c9be3597a7f8189b123><cb35d644a26f0c9be3597a7f8189b123>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF

74
testing/test_pdf_2.pdf Normal file
View File

@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
>>
stream
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000526 00000 n
0000000594 00000 n
0000000890 00000 n
0000000949 00000 n
trailer
<<
/ID
[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF

74
testing/test_pdf_3.pdf Normal file
View File

@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
>>
stream
GarW0YmS?5&4HDC`<2TCEOpM_A^cO6ZEVtG&1rQ7k5R.W5uPe>'T[Ma*9KfZqZs*-57""%'<u)dPtNs!.p_7Cem+LKojd:CaF,4$g:S_<`9sPL'Dq([aoCSX;_^WU4Wa'KgNd255,.iQh#\m&~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000526 00000 n
0000000594 00000 n
0000000890 00000 n
0000000949 00000 n
trailer
<<
/ID
[<8c4eba11c30780ded30147f80c0aa46f><8c4eba11c30780ded30147f80c0aa46f>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF

74
testing/test_pdf_4.pdf Normal file
View File

@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
>>
stream
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3%Qb`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^nh.J$8~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000526 00000 n
0000000594 00000 n
0000000890 00000 n
0000000949 00000 n
trailer
<<
/ID
[<ade40b97468692afaf20f74813f90619><ade40b97468692afaf20f74813f90619>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF