Compare commits

..
234 changed files with 2391 additions and 9423 deletions
+2 -3
View File
@@ -13,10 +13,9 @@
"Bash(npx tsc:*)",
"Bash(node:*)",
"Bash(npm run dev:*)",
"Bash(sed:*)",
"Bash(npm run typecheck:*)"
"Bash(sed:*)"
],
"deny": [],
"defaultMode": "acceptEdits"
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
[{*.js,*.jsx,*.ts,*.tsx}]
indent_size = 2
[*.css]
-2
View File
@@ -147,8 +147,6 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend
run: cd frontend && npm run build
- name: Run frontend tests
-1
View File
@@ -19,6 +19,5 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
]
}
+1 -4
View File
@@ -139,8 +139,5 @@
"app/core/src/main/java",
"app/common/src/main/java",
"app/proprietary/src/main/java"
],
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
]
}
-300
View File
@@ -1,300 +0,0 @@
# Adding New React Tools to Stirling PDF
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
## Overview
When adding tools, follow this systematic approach using the established patterns and architecture.
## 1. Create Tool Structure
Create these files in the correct directories:
```
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
## 2. Implementation Pattern
Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools:
**Parameters Hook** (`use[ToolName]Parameters.ts`):
```typescript
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface [ToolName]Parameters extends BaseParameters {
// Define your tool-specific parameters here
someOption: boolean;
}
export const defaultParameters: [ToolName]Parameters = {
someOption: false,
};
export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => {
return useBaseParameters({
defaultParameters,
endpointName: 'your-endpoint-name',
validateFn: (params) => true, // Add validation logic
});
};
```
**Operation Hook** (`use[ToolName]Operation.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters to formData
return formData;
};
export const [toolName]OperationConfig = {
toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated)
buildFormData: build[ToolName]FormData,
operationType: '[toolName]',
endpoint: '/api/v1/category/endpoint-name',
filePrefix: 'processed_', // Will be overridden with translation
defaultParameters,
} as const;
export const use[ToolName]Operation = () => {
const { t } = useTranslation();
return useToolOperation({
...[toolName]OperationConfig,
filePrefix: t('[toolName].filenamePrefix', 'processed') + '_',
getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed'))
});
};
```
**Main Component** (`[ToolName].tsx`):
```typescript
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters";
import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const [ToolName] = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("[toolName].files.placeholder", "Select files to get started"),
},
steps: [
// Add settings steps if needed
],
executeButton: {
text: t("[toolName].submit", "Process"),
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("[toolName].results.title", "Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
[ToolName].tool = () => use[ToolName]Operation;
export default [ToolName] as ToolComponent;
```
**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support.
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings";
```
2. Add tool entry in the `allTools` object:
```typescript
[toolName]: {
icon: <LocalIcon icon="your-icon-name" width="1.5rem" height="1.5rem" />,
name: t("home.[toolName].title", "Tool Name"),
component: [ToolName],
description: t("home.[toolName].desc", "Tool description"),
categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
maxFiles: -1, // or specific number
endpoints: ["endpoint-name"],
operationConfig: [toolName]OperationConfig,
settingsComponent: [ToolName]Settings, // if settings exist
},
```
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const use[ToolName]Tips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("[toolName].tooltip.header.title", "Tool Overview")
},
tips: [
{
title: t("[toolName].tooltip.description.title", "What does this tool do?"),
description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."),
bullets: [
t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"),
t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2")
]
}
// Add more tip sections as needed
]
};
};
```
**Add tooltip to your main component:**
```typescript
import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
const [ToolName] = (props: BaseToolProps) => {
const tips = use[ToolName]Tips();
// In your steps array:
steps: [
{
title: t("[toolName].steps.settings", "Settings"),
tooltip: tips, // Add this line
content: <[ToolName]Settings ... />
}
]
```
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**:
```json
{
"home": {
"[toolName]": {
"title": "Tool Name",
"desc": "Tool description"
}
},
"[toolName]": {
"title": "Tool Name",
"submit": "Process",
"filenamePrefix": "processed",
"files": {
"placeholder": "Select files to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"title": "Tool Options",
"someOption": "Option Label",
"someOption.desc": "Option description",
"note": "General information about the tool."
},
"results": {
"title": "Results"
},
"error": {
"failed": "Operation failed"
},
"tooltip": {
"header": {
"title": "Tool Overview"
},
"description": {
"title": "What does this tool do?",
"text": "Simple explanation in everyday language",
"bullet1": "Easy-to-understand benefit 1",
"bullet2": "Easy-to-understand benefit 2"
}
}
}
}
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
**Tooltip Writing Guidelines:**
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
- **Focus on benefits** - explain what the user gains, not how it works internally
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
- **Keep descriptions concise** - 1-2 sentences maximum per section
- **Use bullet points** for multiple benefits or features
## 6. Migration from Thymeleaf
When migrating existing Thymeleaf templates:
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
## 7. Testing Your Tool
- Verify tool appears in UI with correct icon and description
- Test with various file sizes and types
- Confirm translations work
- Check error handling
- Test undo functionality
- Verify results display correctly
## Tool Development Patterns
### Three Tool Patterns:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
-1
View File
@@ -208,7 +208,6 @@ return useToolOperation({
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
## Communication Style
- Be direct and to the point
-42
View File
@@ -1,42 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: [
"dist", // Contains 3rd party code
"public", // Contains 3rd party code
],
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "all", // All function args must be used (or explicitly ignored)
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
}
);
-1
View File
@@ -13,7 +13,6 @@
<link rel="manifest" href="/manifest.json" />
<title>Stirling PDF</title>
<!-- EmbedPDF is imported dynamically in Viewer component -->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+66 -1282
View File
File diff suppressed because it is too large Load Diff
+3 -25
View File
@@ -6,21 +6,6 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@embedpdf/core": "^1.1.1",
"@embedpdf/engines": "^1.1.1",
"@embedpdf/plugin-interaction-manager": "^1.1.1",
"@embedpdf/plugin-loader": "^1.1.1",
"@embedpdf/plugin-pan": "^1.1.1",
"@embedpdf/plugin-render": "^1.1.1",
"@embedpdf/plugin-rotate": "^1.1.1",
"@embedpdf/plugin-scroll": "^1.1.1",
"@embedpdf/plugin-search": "^1.1.1",
"@embedpdf/plugin-selection": "^1.1.1",
"@embedpdf/plugin-spread": "^1.1.1",
"@embedpdf/plugin-thumbnail": "^1.1.1",
"@embedpdf/plugin-tiling": "^1.1.1",
"@embedpdf/plugin-viewport": "^1.1.1",
"@embedpdf/plugin-zoom": "^1.1.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0",
@@ -53,18 +38,14 @@
},
"scripts": {
"predev": "npm run generate-icons",
"dev": "npm run typecheck && vite",
"dev": "npx tsc --noEmit && vite",
"prebuild": "npm run generate-icons",
"lint": "eslint",
"build": "npm run typecheck && vite build",
"build": "npx tsc --noEmit && 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",
@@ -90,7 +71,6 @@
]
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@iconify-json/material-symbols": "^1.2.33",
"@iconify/utils": "^3.0.1",
"@playwright/test": "^1.40.0",
@@ -99,7 +79,6 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0",
"eslint": "^9.34.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
@@ -107,8 +86,7 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^1.0.0"
}
+58 -332
View File
@@ -41,9 +41,6 @@
"save": "Save",
"saveToBrowser": "Save to Browser",
"download": "Download",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
"editYourNewFiles": "Edit your new file(s)",
"close": "Close",
"fileSelected": "Selected: {{filename}}",
@@ -51,11 +48,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 ",
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
"selectFromWorkbench": "Select files from the workbench or "
},
"noFavourites": "No favourites added",
"downloadComplete": "Download Complete",
@@ -498,9 +495,13 @@
"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": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlay-pdfs": {
"title": "Overlay PDFs",
@@ -644,29 +645,11 @@
"merge": {
"tags": "merge,Page operations,Back end,server side",
"title": "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."
}
"header": "Merge multiple PDFs (2+)",
"sortByName": "Sort by name",
"sortByDate": "Sort by date",
"removeCertSign": "Remove digital signature in the merged file?",
"submit": "Merge"
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
@@ -683,76 +666,7 @@
"8": "Document #6: Page 10"
},
"splitPages": "Enter pages to split on:",
"submit": "Split",
"error": {
"failed": "An error occurred while splitting the PDF."
},
"method": {
"label": "Choose split method",
"placeholder": "Select how to split the PDF"
},
"methods": {
"byPages": "Split at Page Numbers",
"bySections": "Split by Sections",
"bySize": "Split by File Size",
"byPageCount": "Split by Page Count",
"byDocCount": "Split by Document Count",
"byChapters": "Split by Chapters"
},
"value": {
"fileSize": {
"label": "File Size",
"placeholder": "e.g. 10MB, 500KB"
},
"pageCount": {
"label": "Pages per File",
"placeholder": "e.g. 5, 10"
},
"docCount": {
"label": "Number of Files",
"placeholder": "e.g. 3, 5"
}
},
"tooltip": {
"header": {
"title": "Split Methods Overview"
},
"byPages": {
"title": "Split at Page Numbers",
"text": "Split your PDF at specific page numbers. Using 'n' splits after page n. Using 'n-m' splits before page n and after page m.",
"bullet1": "Single split points: 3,7 (splits after pages 3 and 7)",
"bullet2": "Range split points: 3-8 (splits before page 3 and after page 8)",
"bullet3": "Mixed: 2,5-10,15 (splits after page 2, before page 5, after page 10, and after page 15)"
},
"bySections": {
"title": "Split by Grid Sections",
"text": "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas.",
"bullet1": "Horizontal: Number of rows to create",
"bullet2": "Vertical: Number of columns to create",
"bullet3": "Merge: Combine all sections into one PDF"
},
"bySize": {
"title": "Split by File Size",
"text": "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments.",
"bullet1": "Use MB for larger files (e.g., 10MB)",
"bullet2": "Use KB for smaller files (e.g., 500KB)",
"bullet3": "System will split at page boundaries"
},
"byCount": {
"title": "Split by Count",
"text": "Create multiple PDFs with a specific number of pages or documents each.",
"bullet1": "Page Count: Fixed number of pages per file",
"bullet2": "Document Count: Fixed number of output files",
"bullet3": "Useful for batch processing workflows"
},
"byChapters": {
"title": "Split by Chapters",
"text": "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure.",
"bullet1": "Bookmark Level: Which level to split on (1=top level)",
"bullet2": "Include Metadata: Preserve document properties",
"bullet3": "Allow Duplicates: Handle repeated bookmark names"
}
}
"submit": "Split"
},
"rotate": {
"tags": "server side",
@@ -1388,48 +1302,7 @@
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
"submit": "Flatten",
"filenamePrefix": "flattened",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"stepTitle": "Flatten Options",
"title": "Flatten Options",
"flattenOnlyForms": "Flatten only forms",
"flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact",
"note": "Flattening removes interactive elements from the PDF, making them non-editable."
},
"results": {
"title": "Flatten Results"
},
"error": {
"failed": "An error occurred while flattening the PDF."
},
"tooltip": {
"header": {
"title": "About Flattening PDFs"
},
"description": {
"title": "What does flattening do?",
"text": "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere.",
"bullet1": "Text boxes become regular text (can't be edited)",
"bullet2": "Checkboxes and buttons become pictures",
"bullet3": "Great for final versions you don't want changed",
"bullet4": "Ensures consistent appearance across all devices"
},
"formsOnly": {
"title": "What does 'Flatten only forms' mean?",
"text": "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments.",
"bullet1": "Forms become non-editable",
"bullet2": "Links still work when clicked",
"bullet3": "Comments and notes remain visible",
"bullet4": "Bookmarks still help you navigate"
}
}
"submit": "Flatten"
},
"repair": {
"tags": "fix,restore,correction,recover",
@@ -1537,6 +1410,7 @@
"submit": "Submit"
},
"scalePages": {
"tags": "resize,modify,dimension,adapt",
"title": "Adjust page-scale",
"header": "Adjust page-scale",
"pageSize": "Size of a page of the document.",
@@ -1544,44 +1418,6 @@
"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"
},
@@ -1589,29 +1425,7 @@
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename",
"header": "Auto Rename PDF",
"description": "Automatically finds the title from your PDF content and uses it as the filename.",
"submit": "Auto Rename",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred whilst auto-renaming the PDF."
},
"results": {
"title": "Auto-Rename Results"
},
"tooltip": {
"header": {
"title": "How Auto-Rename Works"
},
"howItWorks": {
"title": "Smart Renaming",
"text": "Automatically finds the title from your PDF content and uses it as the filename.",
"bullet1": "Looks for text that appears to be a title or heading",
"bullet2": "Creates a clean, valid filename from the detected title",
"bullet3": "Keeps the original name if no suitable title is found"
}
}
"submit": "Auto Rename"
},
"adjust-contrast": {
"tags": "color-correction,tune,modify,enhance,colour-correction"
@@ -1703,123 +1517,50 @@
"downloadJS": "Download Javascript",
"submit": "Show"
},
"autoRedact": {
"tags": "Redact,Hide,black out,black,marker,hidden",
"title": "Auto Redact",
"header": "Auto Redact",
"colorLabel": "Colour",
"textsToRedactLabel": "Text to Redact (line-separated)",
"textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-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"
},
"redact": {
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
"title": "Redact",
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
"title": "Manual Redaction",
"header": "Manual Redaction",
"submit": "Redact",
"error": {
"failed": "An error occurred while redacting the PDF."
"textBasedRedaction": "Text based Redaction",
"pageBasedRedaction": "Page-based Redaction",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"pageRedactionNumbers": {
"title": "Pages",
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
},
"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"
"redactionColor": {
"title": "Redaction Color"
},
"auto": {
"header": "Auto Redact",
"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"
},
"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",
"textBasedRedaction": "Text-based Redaction",
"pageBasedRedaction": "Page-based Redaction",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"pageRedactionNumbers": {
"title": "Pages",
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
},
"redactionColor": {
"title": "Redaction Colour"
},
"export": "Export",
"upload": "Upload",
"boxRedaction": "Box draw redaction",
"zoom": "Zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"toggleSidebar": "Toggle Sidebar",
"showThumbnails": "Show Thumbnails",
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
"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"
}
"export": "Export",
"upload": "Upload",
"boxRedaction": "Box draw redaction",
"zoom": "Zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"toggleSidebar": "Toggle Sidebar",
"showThumbnails": "Show Thumbnails",
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
"showAttatchments": "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"
@@ -2030,11 +1771,6 @@
"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"
@@ -2645,15 +2381,5 @@
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"viewer": {
"firstPage": "First Page",
"lastPage": "Last Page",
"previousPage": "Previous Page",
"nextPage": "Next Page",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"singlePageView": "Single Page View",
"dualPageView": "Dual Page View"
}
}
}
+1 -22
View File
@@ -1113,28 +1113,7 @@
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename",
"header": "Auto Rename PDF",
"submit": "Auto Rename",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred while auto-renaming the PDF."
},
"results": {
"title": "Auto-Rename Results"
},
"tooltip": {
"header": {
"title": "How Auto-Rename Works"
},
"howItWorks": {
"title": "Smart Renaming",
"text": "Automatically finds the best title from your PDF content and uses it as the filename.",
"bullet1": "Looks for text that appears to be a title or heading",
"bullet2": "Creates a clean, valid filename from the detected title",
"bullet3": "Keeps the original name if no suitable title is found"
}
}
"submit": "Auto Rename"
},
"adjust-contrast": {
"tags": "color-correction,tune,modify,enhance"
+1 -1
View File
@@ -107,7 +107,7 @@ async function main() {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
}
} catch {
} catch (error) {
// If we can't parse existing file, regenerate
needsRegeneration = true;
}
+2 -2
View File
@@ -24,7 +24,7 @@ try {
// Install license-checker if not present
try {
require.resolve('license-checker');
} catch {
} catch (e) {
console.log('📦 Installing license-checker...');
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
}
@@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) {
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes('AND') || licenseType.includes('OR')) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}
-3
View File
@@ -14,9 +14,6 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
// Loading component for i18next suspense
const LoadingFallback = () => (
<div
+1 -43
View File
@@ -385,13 +385,6 @@
"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",
@@ -749,13 +742,6 @@
"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",
@@ -938,13 +924,6 @@
"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",
@@ -1554,20 +1533,6 @@
"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",
@@ -1963,7 +1928,7 @@
{
"moduleName": "typescript",
"moduleUrl": "https://github.com/microsoft/TypeScript",
"moduleVersion": "5.9.2",
"moduleVersion": "5.8.3",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -2030,13 +1995,6 @@
"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",
+8 -1
View File
@@ -4,6 +4,7 @@ import { Dropzone } from '@mantine/dropzone';
import { FileMetadata } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { createFileId } from '../types/fileContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
@@ -20,7 +21,13 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId);
}, [storeFile]);
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
+143 -24
View File
@@ -1,28 +1,42 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from './FileEditor.module.css';
import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId, StirlingFile } from '../../types/fileContext';
import { FileId } from '../../types/file';
interface FileEditorProps {
onOpenPageEditor?: () => void;
onMergeFiles?: (files: StirlingFile[]) => void;
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
supportedExtensions?: string[];
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
toolMode = false,
showUpload = true,
showBulkActions = true,
supportedExtensions = ["pdf"]
}: FileEditorProps) => {
const { t } = useTranslation();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
@@ -35,10 +49,13 @@ const FileEditor = ({
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
// Get navigation actions
// Get the real context actions
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
// Get file selection context
@@ -75,10 +92,10 @@ const FileEditor = ({
const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Use activeStirlingFileStubs directly - no conversion needed
// Use activeFileRecords directly - no conversion needed
const localSelectedIds = contextSelectedIds;
// Helper to convert StirlingFileStub to FileThumbnail format
// Helper to convert FileRecord to FileThumbnail format
const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id);
if (!file) return null;
@@ -144,9 +161,29 @@ const FileEditor = ({
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */,
status: 'pending',
metadata: {
originalFileName: file.name,
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
fileSize: file.size,
parameters: {
extractionType: 'zip',
extractedCount: extractionResult.extractedCount,
totalFiles: extractionResult.totalFiles
}
}
};
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
@@ -176,6 +213,25 @@ const FileEditor = ({
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Record upload operations for PDF files
for (const file of allExtractedFiles) {
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'upload',
timestamp: Date.now(),
fileIds: [file.name as FileId /* This doesn't seem right */],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
uploadMethod: 'drag-drop'
}
}
};
}
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
@@ -196,10 +252,27 @@ const FileEditor = ({
}
}, [addFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
}, [activeFileRecords, setSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFileRecords.length === 0) return;
// Remove all files from context but keep in storage
const allFileIds = activeFileRecords.map(record => record.id);
removeFiles(allFileIds, false); // false = keep in storage
// Clear selections
setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
const targetRecord = activeFileRecords.find(r => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
@@ -229,12 +302,21 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setSelectedFiles([]);
}
return newMode;
});
}, [setSelectedFiles]);
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map(r => r.id);
const currentIds = activeFileRecords.map(r => r.id);
// Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
@@ -286,34 +368,71 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
}, [activeFileRecords, reorderFiles, setStatus]);
// File operations using context
const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const record = activeFileRecords.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
// Record close operation
const fileName = file.name;
const contextFileId = record.id;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */],
status: 'pending',
metadata: {
originalFileName: fileName,
fileSize: record.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}
};
// Remove file from context but keep in storage (close, don't delete)
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const record = activeFileRecords.find(r => r.id === fileId);
if (record) {
// Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]);
navActions.setWorkbench('viewer');
}
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: FileId) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
if (startIndex === -1) return;
const recordsToMerge = activeFileRecords.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [activeFileRecords, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: FileId) => {
const file = selectors.getFile(fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file);
}
}, [selectors, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
@@ -348,7 +467,7 @@ const FileEditor = ({
<Box p="md" pt="xl">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
@@ -356,7 +475,7 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
@@ -403,7 +522,7 @@ const FileEditor = ({
pointerEvents: 'auto'
}}
>
{activeStirlingFileStubs.map((record, index) => {
{activeFileRecords.map((record, index) => {
const fileItem = recordToFileItem(record);
if (!fileItem) return null;
@@ -412,7 +531,7 @@ const FileEditor = ({
key={record.id}
file={fileItem}
index={index}
totalFiles={activeStirlingFileStubs.length}
totalFiles={activeFileRecords.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
@@ -44,6 +44,7 @@ const FileEditorThumbnail = ({
selectedFiles,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
@@ -60,8 +61,8 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.id]);
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
}, [activeFiles, file.name, file.size]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@@ -11,26 +11,27 @@ interface FileDetailsProps {
compact?: boolean;
}
const FileDetails: React.FC<FileDetailsProps> = ({
const FileDetails: React.FC<FileDetailsProps> = ({
compact = false
}) => {
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Get the currently displayed file
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = selectedFiles.length > 1;
// Use IndexedDB hook for the current file
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
// Get thumbnail for current file
const getCurrentThumbnail = () => {
return currentThumbnail;
};
const handlePrevious = () => {
if (isAnimating) return;
setIsAnimating(true);
@@ -39,7 +40,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
setIsAnimating(false);
}, 150);
};
const handleNext = () => {
if (isAnimating) return;
setIsAnimating(true);
@@ -48,14 +49,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({
setIsAnimating(false);
}, 150);
};
// Reset index when selection changes
React.useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}
}, [selectedFiles.length, currentFileIndex]);
if (compact) {
return (
<CompactFileDetails
@@ -87,26 +88,26 @@ const FileDetails: React.FC<FileDetailsProps> = ({
onNext={handleNext}
/>
</Box>
{/* Section 2: File Details */}
<FileInfoCard
currentFile={currentFile}
modalHeight={modalHeight}
/>
<Button
size="md"
<Button
size="md"
mb="xl"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
style={{
flexShrink: 0,
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
style={{
flexShrink: 0,
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
}}
>
{selectedFiles.length > 1
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
@@ -115,4 +116,4 @@ const FileDetails: React.FC<FileDetailsProps> = ({
);
};
export default FileDetails;
export default FileDetails;
@@ -1,5 +1,5 @@
import React from 'react';
import { Box } from '@mantine/core';
import { Stack, Box } from '@mantine/core';
import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
@@ -19,14 +19,14 @@ const MobileLayout: React.FC = () => {
const calculateFileListHeight = () => {
// Base modal height minus padding and gaps
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
// Estimate heights of fixed components
const fileSourceHeight = '3rem'; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
};
@@ -36,15 +36,15 @@ const MobileLayout: React.FC = () => {
<Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} />
</Box>
<Box style={{ flexShrink: 0 }}>
<FileDetails compact={true} />
</Box>
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
<Box style={{
<Box style={{
flex: 1,
display: 'flex',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
borderRadius: '0.5rem',
@@ -54,13 +54,13 @@ const MobileLayout: React.FC = () => {
}}>
{activeSource === 'recent' && (
<>
<Box style={{
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
<Box style={{
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
@@ -68,11 +68,11 @@ const MobileLayout: React.FC = () => {
</Box>
</>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
<FileListArea
scrollAreaHeight={calculateFileListHeight()}
scrollAreaStyle={{
scrollAreaStyle={{
height: calculateFileListHeight(),
maxHeight: '60vh',
minHeight: '9.375rem',
@@ -83,11 +83,11 @@ const MobileLayout: React.FC = () => {
/>
</Box>
</Box>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Box>
);
};
export default MobileLayout;
export default MobileLayout;
@@ -0,0 +1,181 @@
import React from 'react';
import {
Stack,
Paper,
Text,
Badge,
Group,
Collapse,
Box,
ScrollArea,
Code,
Divider
} from '@mantine/core';
// FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
import { FileId } from '../../types/file';
interface FileOperationHistoryProps {
fileId: FileId;
showOnlyApplied?: boolean;
maxHeight?: number;
}
const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
fileId,
showOnlyApplied = false,
maxHeight = 400
}) => {
// These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: FileId) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: FileId) => [];
const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const getOperationIcon = (type: string) => {
switch (type) {
case 'split': return '✂️';
case 'merge': return '🔗';
case 'compress': return '🗜️';
case 'rotate': return '🔄';
case 'delete': return '🗑️';
case 'move': return '↕️';
case 'insert': return '📄';
case 'upload': return '⬆️';
case 'add': return '➕';
case 'remove': return '➖';
case 'replace': return '🔄';
case 'convert': return '🔄';
default: return '⚙️';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'applied': return 'green';
case 'failed': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
};
const renderOperationDetails = (operation: FileOperation) => {
if ('metadata' in operation && operation.metadata) {
const { metadata } = operation;
return (
<Box mt="xs">
{metadata.parameters && (
<Text size="xs" c="dimmed">
Parameters: <Code>{JSON.stringify(metadata.parameters, null, 2)}</Code>
</Text>
)}
{metadata.originalFileName && (
<Text size="xs" c="dimmed">
Original file: {metadata.originalFileName}
</Text>
)}
{metadata.outputFileNames && (
<Text size="xs" c="dimmed">
Output files: {metadata.outputFileNames.join(', ')}
</Text>
)}
{metadata.fileSize && (
<Text size="xs" c="dimmed">
File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB
</Text>
)}
{metadata.pageCount && (
<Text size="xs" c="dimmed">
Pages: {metadata.pageCount}
</Text>
)}
{metadata.error && (
<Text size="xs" c="red">
Error: {metadata.error}
</Text>
)}
</Box>
);
}
return null;
};
if (!history || operations.length === 0) {
return (
<Paper p="md" withBorder>
<Text c="dimmed" ta="center">
{showOnlyApplied ? 'No applied operations found' : 'No operation history available'}
</Text>
</Paper>
);
}
return (
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Text fw={500}>
{showOnlyApplied ? 'Applied Operations' : 'Operation History'}
</Text>
<Badge variant="light" color="blue">
{operations.length} operations
</Badge>
</Group>
<ScrollArea h={maxHeight}>
<Stack gap="sm">
{operations.map((operation, index) => (
<Paper key={operation.id} p="sm" withBorder radius="sm" bg="gray.0">
<Group justify="space-between" align="start">
<Group gap="xs">
<Text span size="lg">
{getOperationIcon(operation.type)}
</Text>
<Box>
<Text fw={500} size="sm">
{operation.type.charAt(0).toUpperCase() + operation.type.slice(1)}
</Text>
<Text size="xs" c="dimmed">
{formatTimestamp(operation.timestamp)}
</Text>
</Box>
</Group>
<Badge
variant="filled"
color={getStatusColor(operation.status)}
size="sm"
>
{operation.status}
</Badge>
</Group>
{renderOperationDetails(operation)}
{index < operations.length - 1 && <Divider mt="sm" />}
</Paper>
))}
</Stack>
</ScrollArea>
{history && (
<Group justify="space-between" mt="sm" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
<Text size="xs" c="dimmed">
Created: {formatTimestamp(history.createdAt)}
</Text>
<Text size="xs" c="dimmed">
Last modified: {formatTimestamp(history.lastModified)}
</Text>
</Group>
)}
</Paper>
);
};
export default FileOperationHistory;
+7 -2
View File
@@ -1,4 +1,6 @@
import React from 'react';
import { Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
@@ -17,6 +19,7 @@ import Footer from '../shared/Footer';
// No props needed - component uses contexts directly
export default function Workbench() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
@@ -75,9 +78,11 @@ export default function Workbench() {
return (
<FileEditor
toolMode={!!selectedToolId}
showUpload={true}
showBulkActions={!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolId && {
onOpenPageEditor: () => {
onOpenPageEditor: (file) => {
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
@@ -157,7 +162,7 @@ export default function Workbench() {
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
marginTop: 0,
marginTop: '1rem',
}}
>
{renderMainContent()}
@@ -1,6 +1,8 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
import { GRID_CONSTANTS } from './constants';
interface DragDropItem {
@@ -20,60 +22,65 @@ interface DragDropGridProps<T extends DragDropItem> {
const DragDropGrid = <T extends DragDropItem>({
items,
selectedItems,
selectionMode,
isAnimating,
onReorderPages,
renderItem,
renderSplitMarker,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
if (!containerRef.current) return 4; // Default fallback
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
// Update items per row when container resizes
useEffect(() => {
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
setItemsPerRow(newItemsPerRow);
};
// Initial calculation
updateLayout();
// Listen for window resize
window.addEventListener('resize', updateLayout);
// Use ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(updateLayout);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
@@ -85,6 +92,8 @@ const DragDropGrid = <T extends DragDropItem>({
overscan: OVERSCAN,
});
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
@@ -92,9 +101,9 @@ const DragDropGrid = <T extends DragDropItem>({
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
return (
<Box
<Box
ref={containerRef}
style={{
style={{
// Basic container styles
width: '100%',
height: '100%',
@@ -113,7 +122,7 @@ const DragDropGrid = <T extends DragDropItem>({
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex);
return (
<div
key={virtualRow.index}
@@ -145,7 +154,7 @@ const DragDropGrid = <T extends DragDropItem>({
</React.Fragment>
);
})}
</div>
</div>
);
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@@ -44,6 +44,7 @@ const FileThumbnail = ({
selectedFiles,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
@@ -60,8 +61,8 @@ const FileThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.id]);
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
}, [activeFiles, file.name, file.size]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
@@ -92,6 +93,40 @@ const FileThumbnail = ({
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
const bytes = file.size ?? 0;
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}, [file.size]);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toUpperCase();
}, [file.name]);
const pageLabel = useMemo(
() =>
file.pageCount > 0
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
: '',
[file.pageCount]
);
const dateLabel = useMemo(() => {
const d =
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
year: 'numeric',
}).format(d);
}, [file.modifiedAt]);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
@@ -1,7 +1,13 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "../../contexts/FileContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Button, Text, Center, Box,
Notification, TextInput, LoadingOverlay, Modal, Alert,
Stack, Group, Portal
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
// Thumbnail generation is now handled by individual PageThumbnail components
@@ -13,11 +19,16 @@ import NavigationWarningModal from '../shared/NavigationWarningModal';
import { FileId } from "../../types/file";
import {
DOMCommand,
RotatePageCommand,
DeletePagesCommand,
ReorderPagesCommand,
SplitCommand,
BulkRotateCommand,
BulkSplitCommand,
SplitAllCommand,
PageBreakCommand,
BulkPageBreakCommand,
UndoManager
} from './commands/pageCommands';
import { GRID_CONSTANTS } from './constants';
@@ -38,24 +49,35 @@ const PageEditor = ({
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
const selectedFiles = selectors.getSelectedFiles();
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
const processingProgress = state.ui.processingProgress;
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
// Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [foundDraft, setFoundDraft] = useState<any>(null);
const autoSaveTimer = useRef<number | null>(null);
// DOM-first undo manager (replaces the old React state undo system)
const undoManagerRef = useRef(new UndoManager());
// Document state management
const { document: mergedPdfDocument } = usePageDocument();
const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument();
// UI state management
const {
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading,
togglePage, toggleSelectAll, animateReorder
} = usePageEditorState();
@@ -124,6 +146,12 @@ const PageEditor = ({
}).filter(id => id !== '');
}, [displayDocument]);
// Convert selectedPageIds to numbers for components that still need numbers
const selectedPageNumbers = useMemo(() =>
getPageNumbersFromIds(selectedPageIds),
[selectedPageIds, getPageNumbersFromIds]
);
// Select all pages by default when document initially loads
const hasInitializedSelection = useRef(false);
useEffect(() => {
@@ -1,3 +1,4 @@
import React from "react";
import {
Tooltip,
ActionIcon,
@@ -8,7 +9,9 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import DownloadIcon from "@mui/icons-material/Download";
interface PageEditorControlsProps {
// Close/Reset functions
@@ -43,6 +46,7 @@ interface PageEditorControlsProps {
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
@@ -50,7 +54,12 @@ const PageEditorControls = ({
onRotate,
onDelete,
onSplit,
onSplitAll,
onPageBreak,
onPageBreakAll,
onExportAll,
exportLoading,
selectionMode,
selectedPageIds,
displayDocument,
splitPositions,
@@ -52,13 +52,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pageRefs,
onReorderPages,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
onDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
pdfDocument,
setPdfDocument,
splitPositions,
onInsertFiles,
}: PageThumbnailProps) => {
@@ -169,7 +172,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: (_) => {}
onDrop: ({ source }) => {}
});
(element as any).__dragCleanup = () => {
@@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages;
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => {
@@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryStirlingFileStub) {
if (!primaryFileRecord) {
console.log('🎬 PageEditor: No primary file record found, showing loading');
return null;
}
const name =
activeFileIds.length === 1
? (primaryStirlingFileStub.name ?? 'document.pdf')
? (primaryFileRecord.name ?? 'document.pdf')
: activeFileIds
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Build page insertion map from files with insertion positions
@@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
const originalFileIds: FileId[] = [];
activeFileIds.forEach(fileId => {
const record = selectors.getStirlingFileStub(fileId);
const record = selectors.getFileRecord(fileId);
if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []);
@@ -68,15 +68,16 @@ export function usePageDocument(): PageDocumentHook {
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
let totalPageCount = 0;
// Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!stirlingFileStub) {
const fileRecord = selectors.getFileRecord(fileId);
if (!fileRecord) {
return [];
}
const processedFile = stirlingFileStub.processedFile;
const processedFile = fileRecord.processedFile;
let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) {
@@ -143,6 +144,8 @@ export function usePageDocument(): PageDocumentHook {
});
}
totalPageCount = pages.length;
if (pages.length === 0) {
return null;
}
@@ -156,7 +159,7 @@ export function usePageDocument(): PageDocumentHook {
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {
@@ -4,8 +4,6 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from './Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
interface AllToolsNavButtonProps {
activeButton: string;
@@ -15,7 +13,6 @@ interface AllToolsNavButtonProps {
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => {
setActiveButton('tools');
@@ -27,12 +24,6 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, handleClick);
};
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} />
@@ -40,21 +31,18 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
);
return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={'lg'}
variant="subtle"
aria-label={t("quickAccess.allTools", "All Tools")}
onClick={handleClick}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
border: 'none',
borderRadius: '8px',
textDecoration: 'none'
}}
className={isActive ? 'activeIconScale' : ''}
>
@@ -1,216 +0,0 @@
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
});
});
@@ -1,59 +0,0 @@
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;
+3 -3
View File
@@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { StirlingFileStub } from "../../types/fileContext";
import { FileRecord } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: File;
record?: StirlingFileStub;
record?: FileRecord;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
@@ -25,7 +25,7 @@ interface FileCardProps {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
+10 -18
View File
@@ -1,18 +1,18 @@
import { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
import React, { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard";
import { StirlingFileStub } from "../../types/fileContext";
import { FileRecord } from "../../types/fileContext";
import { FileId } from "../../types/file";
interface FileGridProps {
files: Array<{ file: File; record?: StirlingFileStub }>;
files: Array<{ file: File; record?: FileRecord }>;
onRemove?: (index: number) => void;
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void;
onSelect?: (fileId: FileId) => void;
selectedFiles?: FileId[];
showSearch?: boolean;
@@ -123,17 +123,9 @@ const FileGrid = ({
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles
.filter(item => {
if (!item.record?.id) {
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
return false;
}
return true;
})
.map((item, idx) => {
const fileId = item.record!.id; // Safe to assert after filter
const originalIdx = files.findIndex(f => f.record?.id === fileId);
{displayFiles.map((item, idx) => {
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
<FileCard
@@ -24,6 +24,7 @@ export default function Footer({
return (
<div style={{
height: 'var(--footer-height)',
zIndex: 999999,
backgroundColor: 'var(--mantine-color-gray-1)',
borderTop: '1px solid var(--mantine-color-gray-2)',
display: 'flex',
@@ -1,5 +1,5 @@
import React from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from './LocalIcon';
import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [isChanging, setIsChanging] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
@@ -35,6 +36,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
}
// Start transition animation
setIsChanging(true);
setPendingLanguage(value);
// Simulate processing time for smooth transition
@@ -42,6 +44,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
i18n.changeLanguage(value);
setTimeout(() => {
setIsChanging(false);
setPendingLanguage(null);
setOpened(false);
@@ -51,7 +54,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
}, 200);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'];
// Trigger animation when dropdown opens
@@ -74,8 +77,8 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
}
`}
</style>
<Menu
opened={opened}
<Menu
opened={opened}
onChange={setOpened}
width={600}
position={position}
@@ -163,15 +166,15 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: option.value === i18n.language
backgroundColor: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: option.value === i18n.language
color: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: option.value === i18n.language
backgroundColor: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
transform: 'translateY(-1px)',
@@ -220,4 +223,4 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
);
};
export default LanguageSelector;
export default LanguageSelector;
+6 -6
View File
@@ -13,7 +13,7 @@ try {
localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
}
} catch {
} catch (error) {
console.info('ℹ️ Local icons not available - using CDN fallback');
}
@@ -31,10 +31,10 @@ interface LocalIconProps {
*/
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
// Convert our icon naming convention to the local collection format
const iconName = icon.startsWith('material-symbols:')
? icon
const iconName = icon.startsWith('material-symbols:')
? icon
: `material-symbols:${icon}`;
// Development logging (only in dev mode)
if (process.env.NODE_ENV === 'development') {
const logKey = `icon-${iconName}`;
@@ -44,9 +44,9 @@ export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
sessionStorage.setItem(logKey, 'logged');
}
}
// Always render the icon - Iconify will use local if available, CDN if not
return <Icon icon={iconName} {...props} />;
};
export default LocalIcon;
export default LocalIcon;
@@ -3,11 +3,10 @@ import { ActionIcon, Stack, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import LocalIcon from './LocalIcon';
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
@@ -18,12 +17,12 @@ import {
getActiveNavButton,
} from './quickAccessBar/QuickAccessBar';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const QuickAccessBar = forwardRef<HTMLDivElement>(({
}, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -38,52 +37,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
openFilesModal();
};
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
config.onClick();
}
};
// Render navigation button with conditional URL support
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick()
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
);
};
const buttonConfigs: ButtonConfig[] = [
{
@@ -200,7 +153,27 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Stack gap="lg" align="center">
{buttonConfigs.slice(0, -1).map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'lg') : 'lg'}
variant="subtle"
onClick={() => {
config.onClick();
}}
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
{index === 1 && (
+11 -130
View File
@@ -7,18 +7,13 @@ import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import '../../types/embedPdf';
import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
export default function RightRail() {
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
const [currentRotation, setCurrentRotation] = useState(0);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
@@ -32,31 +27,14 @@ export default function RightRail() {
// Navigation view
const { workbench: currentView } = useNavigationState();
// Sync rotation state with EmbedPDF API
useEffect(() => {
if (currentView === 'viewer' && window.embedPdfRotate) {
const updateRotation = () => {
const rotation = window.embedPdfRotate?.getRotation() || 0;
setCurrentRotation(rotation * 90); // Convert enum to degrees
};
// Update rotation immediately
updateRotation();
// Set up periodic updates to keep state in sync
const interval = setInterval(updateRotation, 1000);
return () => clearInterval(interval);
}
}, [currentView]);
// File state and selection
const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
const { removeFiles } = useFileManagement();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items
const getSelectionState = useCallback(() => {
@@ -107,7 +85,7 @@ export default function RightRail() {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
@@ -227,106 +205,9 @@ export default function RightRail() {
</>
)}
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
<div
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView !== 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Search */}
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer'}
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface
visible={true}
onClose={() => {}}
/>
</div>
</Popover.Dropdown>
</Popover>
</Tooltip>
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfPan?.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Left */}
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfRotate?.rotateBackward();
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Right */}
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfRotate?.rotateForward();
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Sidebar Toggle */}
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => window.toggleThumbnailSidebar?.()}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
</div>
<Divider className="right-rail-divider" />
</div>
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
@@ -380,7 +261,7 @@ export default function RightRail() {
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '17.5rem' }}>
<div style={{ minWidth: 280 }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
@@ -477,14 +358,14 @@ export default function RightRail() {
<LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
@@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
return () => {
cancelAnimationFrame(raf);
try { ro.disconnect(); } catch { /* Ignore errors */ }
try { mo.disconnect(); } catch { /* Ignore errors */ }
try { ro.disconnect(); } catch {}
try { mo.disconnect(); } catch {}
};
}
@@ -1,10 +1,10 @@
/**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
*
*
* When a user selects a tool from the All Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the All Tools list.
*
*
* Features:
* - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to All Tools
@@ -16,8 +16,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../../utils/clickHandlers';
import FitText from '../FitText';
import { Tooltip } from '../Tooltip';
@@ -28,9 +26,8 @@ interface ActiveToolButtonProps {
const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton }) => {
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
const indicatorShouldShow = Boolean(
@@ -41,6 +38,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
const [replayAnim, setReplayAnim] = useState<boolean>(false);
const [isAnimating, setIsAnimating] = useState<boolean>(false);
const [isBackHover, setIsBackHover] = useState<boolean>(false);
const prevKeyRef = useRef<string | null>(null);
const collapseTimeoutRef = useRef<number | null>(null);
@@ -73,9 +71,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
replayRafRef.current = requestAnimationFrame(() => {
setReplayAnim(true);
});
setIsAnimating(true);
prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => {
setReplayAnim(false);
setIsAnimating(false);
animTimeoutRef.current = null;
}, 500);
}
@@ -84,8 +84,10 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
clearTimers();
setIndicatorTool(selectedTool);
setIndicatorVisible(true);
setIsAnimating(true);
prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => {
setIsAnimating(false);
animTimeoutRef.current = null;
}, 500);
}
@@ -93,9 +95,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const triggerCollapse = () => {
clearTimers();
setIndicatorVisible(false);
setIsAnimating(true);
collapseTimeoutRef.current = window.setTimeout(() => {
setIndicatorTool(null);
prevKeyRef.current = null;
setIsAnimating(false);
collapseTimeoutRef.current = null;
}, 500); // match CSS transition duration
}
@@ -138,26 +142,21 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
<div className="flex flex-col items-center gap-1">
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
<ActionIcon
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
setActiveButton('tools');
handleBackToTools();
});
}}
size={'xl'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
onClick={() => {
setActiveButton('tools');
handleBackToTools();
}}
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
style={{
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
textDecoration: 'none'
cursor: 'pointer'
}}
>
<span className="iconContainer">
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import React, { useMemo } from 'react';
import { Box, Stack, Text } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
@@ -40,10 +40,12 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
</Stack>
</Box>
))}
{/* Global spacer to allow scrolling past last row in search mode */}
{/* global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 200 }} />
</Stack>
);
};
export default SearchResults;
+5 -1
View File
@@ -1,3 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
@@ -6,11 +8,12 @@ import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core';
import { Stack, ScrollArea } from '@mantine/core';
// No props needed - component uses context
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
@@ -24,6 +27,7 @@ export default function ToolPanel() {
filteredTools,
toolRegistry,
setSearchQuery,
handleBackToTools
} = useToolWorkflow();
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import AddPasswordSettings from './AddPasswordSettings';
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `mock-${key}`);
@@ -1,10 +1,11 @@
import { Stack, PasswordInput, Select } from "@mantine/core";
import React from "react";
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";
interface AddPasswordSettingsProps {
parameters: AddPasswordParameters;
onParameterChange: <K extends keyof AddPasswordParameters>(key: K, value: AddPasswordParameters[K]) => void;
onParameterChange: (key: keyof AddPasswordParameters, value: any) => void;
disabled?: boolean;
}
@@ -1,5 +1,6 @@
import React from "react";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ButtonSelector from "../../shared/ButtonSelector";
interface WatermarkTypeSettingsProps {
watermarkType?: 'text' | 'image';
@@ -11,21 +12,32 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
const { t } = useTranslation();
return (
<ButtonSelector
value={watermarkType}
onChange={onWatermarkTypeChange}
options={[
{
value: 'text',
label: t('watermark.watermarkType.text', 'Text'),
},
{
value: 'image',
label: t('watermark.watermarkType.image', 'Image'),
},
]}
disabled={disabled}
/>
<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')}
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>
);
};
@@ -1,5 +1,5 @@
import React from "react";
import { Stack, TextInput } from "@mantine/core";
import { Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import { removeEmojis } from "../../../utils/textUtils";
@@ -1,64 +0,0 @@
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();
});
});
@@ -1,55 +0,0 @@
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;
@@ -1,24 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AutoRenameParameters } from '../../../hooks/tools/autoRename/useAutoRenameParameters';
interface AutoRenameSettingsProps {
parameters: AutoRenameParameters;
onParameterChange: <K extends keyof AutoRenameParameters>(parameter: K, value: AutoRenameParameters[K]) => void;
disabled?: boolean;
}
const AutoRenameSettings: React.FC<AutoRenameSettingsProps> = (
) => {
const { t } = useTranslation();
return (
<div className="auto-rename-settings">
<p className="text-muted">
{t('autoRename.description', 'This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text.')}
</p>
</div>
);
};
export default AutoRenameSettings;
@@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -38,8 +38,10 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
automationIcon,
setAutomationIcon,
selectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
@@ -82,6 +84,14 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
updateTool(selectedTools.length, newTool);
};
const handleBackClick = () => {
if (hasUnsavedChanges()) {
setUnsavedWarningOpen(true);
} else {
onBack();
}
};
const handleConfirmBack = () => {
setUnsavedWarningOpen(false);
onBack();
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -69,11 +69,11 @@ export default function AutomationEntry({
const toolChain = operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text
component="span"
size="sm"
<Text
component="span"
size="sm"
fw={600}
style={{
style={{
color: 'var(--mantine-primary-color-filled)',
background: 'var(--mantine-primary-color-light)',
padding: '2px 6px',
@@ -241,12 +241,12 @@ export default function AutomationEntry({
// Show tooltip if there's a description OR operations to display
const shouldShowTooltip = description || operations.length > 0;
return shouldShowTooltip ? (
<Tooltip
content={createTooltipContent()}
position="right"
arrow={true}
<Tooltip
content={createTooltipContent()}
position="right"
arrow={true}
delay={500}
>
{boxContent}
@@ -20,11 +20,11 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
const { selectedFiles } = useFileSelection();
const toolRegistry = useFlatToolRegistry();
const cleanup = useResourceCleanup();
// Progress tracking state
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
@@ -74,15 +74,15 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
try {
// Use the automateOperation.executeOperation to handle file consumption properly
await automateOperation.executeOperation(
{
{
automationConfig: automation,
onStepStart: (stepIndex: number, _operationName: string) => {
onStepStart: (stepIndex: number, operationName: string) => {
setCurrentStepIndex(stepIndex);
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
));
},
onStepComplete: (stepIndex: number, _resultFiles: File[]) => {
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
));
@@ -95,7 +95,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
},
selectedFiles
);
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
@@ -118,20 +118,20 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}>✕</span>;
case EXECUTION_STATUS.RUNNING:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />;
default:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderRadius: '50%'
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderRadius: '50%'
}} />;
}
};
@@ -170,8 +170,8 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
{getStepIcon(step)}
<div style={{ flex: 1 }}>
<Text
size="sm"
<Text
size="sm"
style={{
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
@@ -220,4 +220,4 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
</style>
</div>
);
}
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
@@ -32,6 +32,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({});
const [isValid, setIsValid] = useState(true);
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
@@ -86,7 +87,9 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
};
const handleSave = () => {
onSave(parameters);
if (isValid) {
onSave(parameters);
}
};
return (
@@ -124,6 +127,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
<Button
leftSection={<CheckIcon />}
onClick={handleSave}
disabled={!isValid}
>
{t('automate.config.save', 'Save Configuration')}
</Button>
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
@@ -93,7 +93,7 @@ export default function ToolSelector({
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true)
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
), [displayGroups, handleToolSelect, isSearching, t]
);
@@ -150,7 +150,7 @@ export default function ToolSelector({
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
borderRadius: "var(--mantine-radius-lg)" }}>
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
onSelect={()=>{}} rounded={true}></ToolButton>
</div>
) : (
// Show search input when no tool selected OR when dropdown is opened
@@ -1,10 +1,10 @@
import { Stack, Checkbox } from "@mantine/core";
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
interface ChangePermissionsSettingsProps {
parameters: ChangePermissionsParameters;
onParameterChange: <K extends keyof ChangePermissionsParameters>(key: K, value: ChangePermissionsParameters[K]) => void;
onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void;
disabled?: boolean;
}
@@ -1,12 +1,11 @@
import React, { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { Button, 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: <K extends keyof CompressParameters>(key: K, value: CompressParameters[K]) => void;
onParameterChange: (key: keyof CompressParameters, value: any) => void;
disabled?: boolean;
}
@@ -19,16 +18,33 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<Divider ml='-md'></Divider>
{/* Compression Method */}
<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}
/>
<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')}
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' && (
@@ -5,40 +5,40 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromEmailSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromEmailSettings = ({
parameters,
onParameterChange,
disabled = false
const ConvertFromEmailSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromEmailSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="email-settings">
<Text size="sm" fw={500}>{t("convert.emailOptions", "Email to PDF Options")}:</Text>
<Checkbox
label={t("convert.includeAttachments", "Include email attachments")}
checked={parameters.emailOptions.includeAttachments}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAttachments: event.currentTarget.checked
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAttachments: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-attachments-checkbox"
/>
{parameters.emailOptions.includeAttachments && (
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}:</Text>
<NumberInput
value={parameters.emailOptions.maxAttachmentSizeMB}
onChange={(value) => onParameterChange('emailOptions', {
...parameters.emailOptions,
maxAttachmentSizeMB: Number(value) || 10
onChange={(value) => onParameterChange('emailOptions', {
...parameters.emailOptions,
maxAttachmentSizeMB: Number(value) || 10
})}
min={1}
max={100}
@@ -48,24 +48,24 @@ const ConvertFromEmailSettings = ({
/>
</Stack>
)}
<Checkbox
label={t("convert.includeAllRecipients", "Include CC and BCC recipients in header")}
checked={parameters.emailOptions.includeAllRecipients}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAllRecipients: event.currentTarget.checked
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAllRecipients: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-all-recipients-checkbox"
/>
<Checkbox
label={t("convert.downloadHtml", "Download HTML intermediate file instead of PDF")}
checked={parameters.emailOptions.downloadHtml}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
downloadHtml: event.currentTarget.checked
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
downloadHtml: event.currentTarget.checked
})}
disabled={disabled}
data-testid="download-html-checkbox"
@@ -74,4 +74,4 @@ const ConvertFromEmailSettings = ({
);
};
export default ConvertFromEmailSettings;
export default ConvertFromEmailSettings;
@@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertFromImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
@@ -5,28 +5,28 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromWebSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromWebSettings = ({
parameters,
onParameterChange,
disabled = false
const ConvertFromWebSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromWebSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="web-settings">
<Text size="sm" fw={500}>{t("convert.webOptions", "Web to PDF Options")}:</Text>
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.zoomLevel", "Zoom Level")}:</Text>
<NumberInput
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: Number(value) || 1.0
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: Number(value) || 1.0
})}
min={0.1}
max={3.0}
@@ -36,9 +36,9 @@ const ConvertFromWebSettings = ({
/>
<Slider
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: value
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: value
})}
min={0.1}
max={3.0}
@@ -51,4 +51,4 @@ const ConvertFromWebSettings = ({
);
};
export default ConvertFromWebSettings;
export default ConvertFromWebSettings;
@@ -22,13 +22,13 @@ import {
OUTPUT_OPTIONS,
FIT_OPTIONS
} from "../../../constants/convertConstants";
import { StirlingFile } from "../../../types/fileContext";
import { FileId } from "../../../types/file";
interface ConvertSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: StirlingFile[];
selectedFiles: File[];
disabled?: boolean;
}
@@ -129,7 +129,7 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
@@ -143,8 +143,21 @@ const ConvertSettings = ({
});
};
const updateFileSelection = (files: StirlingFile[]) => {
const fileIds = files.map(file => file.fileId);
const updateFileSelection = (files: File[]) => {
// Map File objects to their actual IDs in FileContext
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
setSelectedFiles(fileIds);
};
@@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertToImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
@@ -3,20 +3,19 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
selectedFiles: StirlingFile[];
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
selectedFiles: File[];
disabled?: boolean;
}
const ConvertToPdfaSettings = ({
parameters,
const ConvertToPdfaSettings = ({
parameters,
onParameterChange,
selectedFiles,
disabled = false
disabled = false
}: ConvertToPdfaSettingsProps) => {
const { t } = useTranslation();
const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles);
@@ -29,7 +28,7 @@ const ConvertToPdfaSettings = ({
return (
<Stack gap="sm" data-testid="pdfa-settings">
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
{hasDigitalSignatures && (
<Alert color="yellow">
<Text size="sm">
@@ -37,14 +36,14 @@ const ConvertToPdfaSettings = ({
</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.outputFormat", "Output Format")}:</Text>
<Select
value={parameters.pdfaOptions.outputFormat}
onChange={(value) => onParameterChange('pdfaOptions', {
...parameters.pdfaOptions,
outputFormat: value || 'pdfa-1'
onChange={(value) => onParameterChange('pdfaOptions', {
...parameters.pdfaOptions,
outputFormat: value || 'pdfa-1'
})}
data={pdfaFormatOptions}
disabled={disabled || isChecking}
@@ -58,4 +57,4 @@ const ConvertToPdfaSettings = ({
);
};
export default ConvertToPdfaSettings;
export default ConvertToPdfaSettings;
@@ -1,35 +0,0 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { FlattenParameters } from "../../../hooks/tools/flatten/useFlattenParameters";
interface FlattenSettingsProps {
parameters: FlattenParameters;
onParameterChange: <K extends keyof FlattenParameters>(key: K, value: FlattenParameters[K]) => void;
disabled?: boolean;
}
const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Stack gap="sm">
<Checkbox
checked={parameters.flattenOnlyForms}
onChange={(event) => onParameterChange('flattenOnlyForms', event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{t('flatten.options.flattenOnlyForms', 'Flatten only forms')}</Text>
<Text size="xs" c="dimmed">
{t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')}
</Text>
</div>
}
/>
</Stack>
</Stack>
);
};
export default FlattenSettings;
@@ -1,182 +0,0 @@
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);
});
});
@@ -1,77 +0,0 @@
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;
@@ -1,100 +0,0 @@
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?');
});
});
@@ -1,38 +0,0 @@
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;
@@ -16,7 +16,7 @@ interface AdvancedOption {
interface AdvancedOCRSettingsProps {
advancedOptions: string[];
ocrRenderType?: string;
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
disabled?: boolean;
}
@@ -40,7 +40,7 @@ const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
// Handle individual checkbox changes
const handleCheckboxChange = (optionValue: string, checked: boolean) => {
const option = advancedOptionsData.find(opt => opt.value === optionValue);
if (option?.isSpecial) {
// Handle special options (like compatibility mode) differently
if (optionValue === 'compatibilityMode') {
@@ -69,7 +69,7 @@ const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
<Text size="sm" fw={500} mb="md">
{t('ocr.settings.advancedOptions.label', 'Processing Options')}
</Text>
<Stack gap="sm">
{advancedOptionsData.map((option) => (
<Checkbox
@@ -87,4 +87,4 @@ const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
);
};
export default AdvancedOCRSettings;
export default AdvancedOCRSettings;
@@ -1,12 +1,12 @@
import React from 'react';
import { Stack, Select, Divider } from '@mantine/core';
import { Stack, Select, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LanguagePicker from './LanguagePicker';
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
interface OCRSettingsProps {
parameters: OCRParameters;
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
disabled?: boolean;
}
@@ -1,211 +0,0 @@
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');
});
});
@@ -1,69 +0,0 @@
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;
@@ -1,33 +0,0 @@
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}
/>
);
}
@@ -1,183 +0,0 @@
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();
});
});
@@ -1,61 +0,0 @@
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;
@@ -1,191 +0,0 @@
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();
});
});
@@ -1,99 +0,0 @@
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>
);
}
@@ -8,7 +8,11 @@ interface RemoveCertificateSignSettingsProps {
disabled?: boolean;
}
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation();
return (
@@ -20,4 +24,4 @@ const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps
);
};
export default RemoveCertificateSignSettings;
export default RemoveCertificateSignSettings;
@@ -1,10 +1,10 @@
import { Stack, PasswordInput } from "@mantine/core";
import { Stack, Text, PasswordInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";
interface RemovePasswordSettingsProps {
parameters: RemovePasswordParameters;
onParameterChange: <K extends keyof RemovePasswordParameters>(key: K, value: RemovePasswordParameters[K]) => void;
onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void;
disabled?: boolean;
}
@@ -8,7 +8,11 @@ interface RepairSettingsProps {
disabled?: boolean;
}
const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
const RepairSettings: React.FC<RepairSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
@@ -20,4 +24,4 @@ const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
);
};
export default RepairSettings;
export default RepairSettings;
@@ -4,7 +4,7 @@ import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sani
interface SanitizeSettingsProps {
parameters: SanitizeParameters;
onParameterChange: <K extends keyof SanitizeParameters>(key: K, value: SanitizeParameters[K]) => void;
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void;
disabled?: boolean;
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import React, { useState, useEffect } from "react";
import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next";
import FolderIcon from '@mui/icons-material/Folder';
@@ -6,20 +6,18 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager";
import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: StirlingFile[];
minFiles?: number;
selectedFiles?: File[];
placeholder?: string;
}
const FileStatusIndicator = ({
selectedFiles = [],
minFiles = 1,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { files: stirlingFileStubs } = useAllFiles();
const { files: workbenchFiles } = useAllFiles();
const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
@@ -29,7 +27,7 @@ const FileStatusIndicator = ({
try {
const recentFiles = await loadRecentFiles();
setHasRecentFiles(recentFiles.length > 0);
} catch {
} catch (error) {
setHasRecentFiles(false);
}
};
@@ -56,16 +54,8 @@ 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 (workbenchFiles.length === 0) {
// If no recent files, show upload button
if (!hasRecentFiles) {
return (
@@ -98,12 +88,12 @@ const FileStatusIndicator = ({
}
// Show selection status when there are files in workbench
if (selectedFiles.length < minFiles) {
if (selectedFiles.length === 0) {
// If no recent files, show upload option
if (!hasRecentFiles) {
return (
<Text size="sm" c="dimmed">
{getPlaceholder() + " "}
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={handleNativeUpload}
@@ -118,7 +108,7 @@ const FileStatusIndicator = ({
// If there are recent files, show add files option
return (
<Text size="sm" c="dimmed">
{getPlaceholder() + " "}
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}
@@ -134,7 +124,7 @@ const FileStatusIndicator = ({
return (
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
</Text>
);
};
@@ -1,13 +1,12 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
selectedFiles: File[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
minFiles?: number;
placeholder?: string;
}
export function createFilesToolStep(
@@ -23,7 +22,7 @@ export function createFilesToolStep(
}, (
<FileStatusIndicator
selectedFiles={props.selectedFiles}
minFiles={props.minFiles}
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
/>
));
}
@@ -1,48 +1,27 @@
import React, { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import UndoIcon from "@mui/icons-material/Undo";
import ErrorNotification from "./ErrorNotification";
import ResultsPreview from "./ResultsPreview";
import { SuggestedToolsSection } from "./SuggestedToolsSection";
import { ToolOperationHook } from "../../../hooks/tools/shared/useToolOperation";
import { Tooltip } from "../../shared/Tooltip";
import React, { useEffect, useRef } from 'react';
import { Button, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
import ErrorNotification from './ErrorNotification';
import ResultsPreview from './ResultsPreview';
import { SuggestedToolsSection } from './SuggestedToolsSection';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
export interface ReviewToolStepProps<TParams = unknown> {
isVisible: boolean;
operation: ToolOperationHook<TParams>;
title?: string;
onFileClick?: (file: File) => void;
onUndo: () => void;
}
function ReviewStepContent<TParams = unknown>({
operation,
onFileClick,
onUndo,
}: {
operation: ToolOperationHook<TParams>;
onFileClick?: (file: File) => void;
onUndo: () => void;
}) {
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
const { t } = useTranslation();
const stepRef = useRef<HTMLDivElement>(null);
const handleUndo = async () => {
try {
onUndo();
} catch (error) {
// Error is already handled by useToolOperation, just reset loading state
console.error("Undo operation failed:", error);
}
};
const previewFiles =
operation.files?.map((file, index) => ({
file,
thumbnail: operation.thumbnails[index],
})) || [];
const previewFiles = operation.files?.map((file, index) => ({
file,
thumbnail: operation.thumbnails[index]
})) || [];
// Auto-scroll to bottom when content appears
useEffect(() => {
@@ -52,7 +31,7 @@ function ReviewStepContent<TParams = unknown>({
setTimeout(() => {
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: "smooth",
behavior: 'smooth'
});
}, 100); // Small delay to ensure content is rendered
}
@@ -61,7 +40,10 @@ function ReviewStepContent<TParams = unknown>({
return (
<Stack gap="sm" ref={stepRef}>
<ErrorNotification error={operation.errorMessage} onClose={operation.clearError} />
<ErrorNotification
error={operation.errorMessage}
onClose={operation.clearError}
/>
{previewFiles.length > 0 && (
<ResultsPreview
@@ -71,18 +53,7 @@ function ReviewStepContent<TParams = unknown>({
/>
)}
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
<Button
leftSection={<UndoIcon />}
variant="outline"
color="var(--mantine-color-gray-6)"
onClick={handleUndo}
fullWidth
>
{t("undo", "Undo")}
</Button>
</Tooltip>
{operation.downloadUrl && (
{operation.downloadUrl && (
<Button
component="a"
href={operation.downloadUrl}
@@ -107,13 +78,14 @@ export function createReviewToolStep<TParams = unknown>(
): React.ReactElement {
const { t } = useTranslation();
return createStep(
t("review", "Review"),
{
isVisible: props.isVisible,
_excludeFromCount: true,
_noPadding: true,
},
<ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
);
return createStep(t("review", "Review"), {
isVisible: props.isVisible,
_excludeFromCount: true,
_noPadding: true
}, (
<ReviewStepContent
operation={props.operation}
onFileClick={props.onFileClick}
/>
));
}
@@ -1,8 +1,10 @@
import React from 'react';
import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement {
const { t } = useTranslation();
const suggestedTools = useSuggestedTools();
@@ -19,25 +21,20 @@ export function SuggestedToolsSection(): React.ReactElement {
{suggestedTools.map((tool) => {
const IconComponent = tool.icon;
return (
<Anchor
<Card
key={tool.id}
href={tool.href}
onClick={tool.onClick}
style={{ textDecoration: 'none', color: 'inherit' }}
p="sm"
withBorder
style={{ cursor: 'pointer' }}
onClick={tool.navigate}
>
<Card
p="sm"
withBorder
style={{ cursor: 'pointer' }}
>
<Group gap="xs">
<IconComponent fontSize="small" />
<Text size="sm" fw={500}>
{tool.title}
</Text>
</Group>
</Card>
</Anchor>
<Group gap="xs">
<IconComponent fontSize="small" />
<Text size="sm" fw={500}>
{tool.title}
</Text>
</Group>
</Card>
);
})}
</Stack>
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react';
import { Text, Stack, Flex, Divider } from '@mantine/core';
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../../types/tips';
@@ -53,7 +53,7 @@ const renderTooltipTitle = (
<Text fw={400} size="sm">
{title}
</Text>
<LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
</Flex>
</Tooltip>
);
@@ -5,7 +5,6 @@ import { Tooltip } from '../../shared/Tooltip';
export interface ToolWorkflowTitleProps {
title: string;
description?: string;
tooltip?: {
content?: React.ReactNode;
tips?: any[];
@@ -16,19 +15,10 @@ export interface ToolWorkflowTitleProps {
};
}
export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowTitleProps) {
const titleContent = (
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={500} size="lg" p="xs">
{title}
</Text>
{tooltip && <LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
</Flex>
);
return (
<>
{tooltip ? (
export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
if (tooltip) {
return (
<>
<Flex justify="center" w="100%">
<Tooltip
content={tooltip.content}
@@ -36,17 +26,27 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT
header={tooltip.header}
sidebarTooltip={true}
>
{titleContent}
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={500} size="xl" p="md">
{title}
</Text>
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
</Flex>
</Tooltip>
</Flex>
) : (
titleContent
)}
<Divider />
</>
);
}
<Text size="sm" mb="md" p="sm" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
{description}
</Text>
<Divider mb="sm" />
return (
<>
<Flex justify="center" w="100%">
<Text fw={500} size="xl" p="md">
{title}
</Text>
</Flex>
<Divider />
</>
);
}
@@ -4,12 +4,11 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
selectedFiles: File[];
isCollapsed?: boolean;
minFiles?: number;
placeholder?: string;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
@@ -44,7 +43,6 @@ export interface ReviewStepConfig {
operation: ToolOperationHook<any>;
title: string;
onFileClick?: (file: File) => void;
onUndo: () => void;
testId?: string;
}
@@ -76,12 +74,12 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
minFiles: config.files.minFiles,
placeholder: config.files.placeholder,
onCollapsedClick: config.files.onCollapsedClick
})}
{/* Middle Steps */}
{config.steps.map((stepConfig) =>
{config.steps.map((stepConfig, index) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
@@ -107,8 +105,7 @@ export function createToolFlow(config: ToolFlowConfig) {
isVisible: config.review.isVisible,
operation: config.review.operation,
title: config.review.title,
onFileClick: config.review.onFileClick,
onUndo: config.review.onUndo
onFileClick: config.review.onFileClick
})}
</ToolStepProvider>
</Stack>
@@ -1,4 +1,5 @@
import { Box } from '@mantine/core';
import React from 'react';
import { Box, Stack } from '@mantine/core';
import ToolButton from '../toolPicker/ToolButton';
import SubcategoryHeader from './SubcategoryHeader';
@@ -12,8 +13,7 @@ export const renderToolButtons = (
subcategory: SubcategoryGroup,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true,
disableNavigation: boolean = false
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
@@ -27,7 +27,6 @@ export const renderToolButtons = (
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
/>
))}
</div>
@@ -8,7 +8,11 @@ interface SingleLargePageSettingsProps {
disabled?: boolean;
}
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
@@ -20,4 +24,4 @@ const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
);
};
export default SingleLargePageSettings;
export default SingleLargePageSettings;
@@ -1,11 +1,11 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { isSplitMethod, SPLIT_METHODS } from '../../../constants/splitConstants';
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
export interface SplitSettingsProps {
parameters: SplitParameters;
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
disabled?: boolean;
}
@@ -57,37 +57,28 @@ const SplitSettings = ({
</Stack>
);
const renderSplitValueForm = () => {
let label, placeholder;
switch (parameters.method) {
case SPLIT_METHODS.BY_SIZE:
label = t("split.value.fileSize.label", "File Size");
placeholder = t("split.value.fileSize.placeholder", "e.g. 10MB, 500KB");
break;
case SPLIT_METHODS.BY_PAGE_COUNT:
label = t("split.value.pageCount.label", "Pages per File");
placeholder = t("split.value.pageCount.placeholder", "e.g. 5, 10");
break;
case SPLIT_METHODS.BY_DOC_COUNT:
label = t("split.value.docCount.label", "Number of Files");
placeholder = t("split.value.docCount.placeholder", "e.g. 3, 5");
break;
default:
label = t("split-by-size-or-count.value.label", "Split Value");
placeholder = t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages");
}
return (
const renderBySizeOrCountForm = () => (
<Stack gap="sm">
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType}
onChange={(v) => v && onParameterChange('splitType', v)}
disabled={disabled}
data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
{ value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
{ value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") },
]}
/>
<TextInput
label={label}
placeholder={placeholder}
label={t("split-by-size-or-count.value.label", "Split Value")}
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
value={parameters.splitValue}
onChange={(e) => onParameterChange('splitValue', e.target.value)}
disabled={disabled}
/>
);
};
</Stack>
);
const renderByChaptersForm = () => (
<Stack gap="sm">
@@ -115,30 +106,26 @@ const SplitSettings = ({
return (
<Stack gap="md">
{/* Method Selector */}
{/* Mode Selector */}
<Select
label={t("split.method.label", "Choose split method")}
placeholder={t("split.method.placeholder", "Select how to split the PDF")}
value={parameters.method}
onChange={(v) => isSplitMethod(v) && onParameterChange('method', v)}
label="Choose split method"
placeholder="Select how to split the PDF"
value={parameters.mode}
onChange={(v) => isSplitMode(v) && onParameterChange('mode', v)}
disabled={disabled}
data={[
{ value: SPLIT_METHODS.BY_PAGES, label: t("split.methods.byPages", "Split at Pages Numbers") },
{ value: SPLIT_METHODS.BY_SECTIONS, label: t("split.methods.bySections", "Split by Sections") },
{ value: SPLIT_METHODS.BY_SIZE, label: t("split.methods.bySize", "Split by Size") },
{ value: SPLIT_METHODS.BY_PAGE_COUNT, label: t("split.methods.byPageCount", "Split by Page Count") },
{ value: SPLIT_METHODS.BY_DOC_COUNT, label: t("split.methods.byDocCount", "Split by Document Count") },
{ value: SPLIT_METHODS.BY_CHAPTERS, label: t("split.methods.byChapters", "Split by Chapters") },
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
{ value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") },
{ value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") },
{ value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") },
]}
/>
{/* Parameter Form */}
{parameters.method === SPLIT_METHODS.BY_PAGES && renderByPagesForm()}
{parameters.method === SPLIT_METHODS.BY_SECTIONS && renderBySectionsForm()}
{(parameters.method === SPLIT_METHODS.BY_SIZE ||
parameters.method === SPLIT_METHODS.BY_PAGE_COUNT ||
parameters.method === SPLIT_METHODS.BY_DOC_COUNT) && renderSplitValueForm()}
{parameters.method === SPLIT_METHODS.BY_CHAPTERS && renderByChaptersForm()}
{parameters.mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
{parameters.mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
{parameters.mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
{parameters.mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
</Stack>
);
}
@@ -2,8 +2,6 @@ import React from "react";
import { Button } from "@mantine/core";
import { Tooltip } from "../../shared/Tooltip";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { useToolNavigation } from "../../../hooks/useToolNavigation";
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
interface ToolButtonProps {
@@ -12,13 +10,10 @@ interface ToolButtonProps {
isSelected: boolean;
onSelect: (id: string) => void;
rounded?: boolean;
disableNavigation?: boolean;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const isUnavailable = !tool.component && !tool.link;
const { getToolNavigation } = useToolNavigation();
const handleClick = (id: string) => {
if (isUnavailable) return;
if (tool.link) {
@@ -30,84 +25,32 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
onSelect(id);
};
// Get navigation props for URL support (only if navigation is not disabled)
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
: tool.description;
const buttonContent = (
<>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
</>
);
const handleExternalClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => handleClick(id));
};
const buttonElement = navProps ? (
// For internal tools with URLs, render Button as an anchor for proper link behavior
<Button
component="a"
href={navProps.href}
onClick={navProps.onClick}
variant={isSelected ? "filled" : "subtle"}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{buttonContent}
</Button>
) : tool.link && !isUnavailable ? (
// For external links, render Button as an anchor with proper href
<Button
component="a"
href={tool.link}
target="_blank"
rel="noopener noreferrer"
onClick={handleExternalClick}
variant={isSelected ? "filled" : "subtle"}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{buttonContent}
</Button>
) : (
// For unavailable tools, use regular button
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={() => handleClick(id)}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
{buttonContent}
</Button>
);
return (
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
{buttonElement}
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={()=> handleClick(id)}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
</Button>
</Tooltip>
);
};
@@ -126,7 +126,7 @@ const ToolSearch = ({
key={id}
variant="subtle"
onClick={() => {
onToolSelect?.(id);
onToolSelect && onToolSelect(id);
setDropdownOpen(false);
}}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
@@ -8,7 +8,11 @@ interface UnlockPdfFormsSettingsProps {
disabled?: boolean;
}
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = (_) => {
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation();
return (
@@ -20,4 +24,4 @@ const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = (_) => {
);
};
export default UnlockPdfFormsSettings;
export default UnlockPdfFormsSettings;
@@ -1,31 +0,0 @@
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.")
}
]
};
};
@@ -1,22 +0,0 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useAutoRenameTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works")
},
tips: [
{
title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"),
bullets: [
t("auto-rename.tooltip.howItWorks.bullet1", "Looks for text that appears to be a title or heading"),
t("auto-rename.tooltip.howItWorks.bullet2", "Creates a clean, valid filename from the detected title"),
t("auto-rename.tooltip.howItWorks.bullet3", "Keeps the original name if no suitable title is found")
]
}
]
};
};
@@ -1,34 +0,0 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useFlattenTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("flatten.tooltip.header.title", "About Flattening PDFs")
},
tips: [
{
title: t("flatten.tooltip.description.title", "What does flattening do?"),
description: t("flatten.tooltip.description.text", "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere."),
bullets: [
t("flatten.tooltip.description.bullet1", "Text boxes become regular text (can't be edited)"),
t("flatten.tooltip.description.bullet2", "Checkboxes and buttons become pictures"),
t("flatten.tooltip.description.bullet3", "Great for final versions you don't want changed"),
t("flatten.tooltip.description.bullet4", "Ensures consistent appearance across all devices")
]
},
{
title: t("flatten.tooltip.formsOnly.title", "What does 'Flatten only forms' mean?"),
description: t("flatten.tooltip.formsOnly.text", "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments."),
bullets: [
t("flatten.tooltip.formsOnly.bullet1", "Forms become non-editable"),
t("flatten.tooltip.formsOnly.bullet2", "Links still work when clicked"),
t("flatten.tooltip.formsOnly.bullet3", "Comments and notes remain visible"),
t("flatten.tooltip.formsOnly.bullet4", "Bookmarks still help you navigate")
]
}
]
};
};
@@ -1,19 +0,0 @@
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.')
}
]
};
};

Some files were not shown because too many files have changed in this diff Show More