mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-24 04:26:14 +00:00
Compare commits
4 Commits
87c63efcec
...
11d23a2d43
Author | SHA1 | Date | |
---|---|---|---|
![]() |
11d23a2d43 | ||
![]() |
1898df0df9 | ||
![]() |
da359d329d | ||
![]() |
bd13f6bf57 |
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -139,5 +139,8 @@
|
||||
"app/core/src/main/java",
|
||||
"app/common/src/main/java",
|
||||
"app/proprietary/src/main/java"
|
||||
]
|
||||
],
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
|
300
ADDING_TOOLS.md
Normal file
300
ADDING_TOOLS.md
Normal file
@ -0,0 +1,300 @@
|
||||
# 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
|
@ -208,6 +208,7 @@ 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
|
||||
|
@ -25,7 +25,18 @@ export default defineConfig(
|
||||
],
|
||||
"@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": "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)
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -1305,7 +1305,48 @@
|
||||
"title": "Flatten",
|
||||
"header": "Flatten PDF",
|
||||
"flattenOnlyForms": "Flatten only forms",
|
||||
"submit": "Flatten"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore,correction,recover",
|
||||
@ -1428,7 +1469,29 @@
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename",
|
||||
"header": "Auto Rename PDF",
|
||||
"submit": "Auto Rename"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adjust-contrast": {
|
||||
"tags": "color-correction,tune,modify,enhance,colour-correction"
|
||||
|
@ -1113,7 +1113,28 @@
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename",
|
||||
"header": "Auto Rename PDF",
|
||||
"submit": "Auto Rename"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adjust-contrast": {
|
||||
"tags": "color-correction,tune,modify,enhance"
|
||||
|
@ -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 (error) {
|
||||
} catch {
|
||||
// If we can't parse existing file, regenerate
|
||||
needsRegeneration = true;
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ try {
|
||||
// Install license-checker if not present
|
||||
try {
|
||||
require.resolve('license-checker');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.log('📦 Installing license-checker...');
|
||||
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ 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';
|
||||
@ -21,13 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||
|
||||
// Wrapper for storeFile that generates UUID
|
||||
const storeStirlingFile = useCallback(async (file: File) => {
|
||||
const fileId = createFileId(); // Generate UUID for storage
|
||||
return await storeFile(file, fileId);
|
||||
}, [storeFile]);
|
||||
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
|
@ -1,42 +1,28 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement } 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';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: StirlingFile) => void;
|
||||
onOpenPageEditor?: () => void;
|
||||
onMergeFiles?: (files: StirlingFile[]) => 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 => {
|
||||
@ -49,13 +35,10 @@ const FileEditor = ({
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const isProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get the real context actions
|
||||
const { actions } = useFileActions();
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
// Get file selection context
|
||||
@ -161,26 +144,6 @@ const FileEditor = ({
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// 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);
|
||||
}
|
||||
@ -213,25 +176,6 @@ 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`);
|
||||
@ -252,23 +196,6 @@ const FileEditor = ({
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly
|
||||
}, [activeStirlingFileStubs, setSelectedFiles]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
||||
|
||||
const closeAllFiles = useCallback(() => {
|
||||
if (activeStirlingFileStubs.length === 0) return;
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
const allFileIds = activeStirlingFileStubs.map(record => record.id);
|
||||
removeFiles(allFileIds, false); // false = keep in storage
|
||||
|
||||
// Clear selections
|
||||
setSelectedFiles([]);
|
||||
}, [activeStirlingFileStubs, removeFiles, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
@ -304,15 +231,6 @@ const FileEditor = ({
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
|
||||
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[]) => {
|
||||
@ -378,27 +296,8 @@ const FileEditor = ({
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
|
||||
if (record && file) {
|
||||
// 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)
|
||||
const contextFileId = record.id;
|
||||
removeFiles([contextFileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
@ -416,24 +315,6 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
||||
const startIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const recordsToMerge = activeStirlingFileStubs.slice(startIndex);
|
||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[];
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [activeStirlingFileStubs, 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;
|
||||
|
||||
|
@ -44,7 +44,6 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Button, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||
@ -22,7 +22,6 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
// 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);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Stack, Box } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import FileSourceButtons from './FileSourceButtons';
|
||||
import FileDetails from './FileDetails';
|
||||
import SearchInput from './SearchInput';
|
||||
|
@ -1,181 +0,0 @@
|
||||
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;
|
@ -1,6 +1,4 @@
|
||||
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';
|
||||
@ -19,7 +17,6 @@ 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
|
||||
@ -78,11 +75,9 @@ export default function Workbench() {
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
showUpload={true}
|
||||
showBulkActions={!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: (file) => {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
|
@ -1,8 +1,6 @@
|
||||
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 {
|
||||
@ -22,12 +20,7 @@ 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);
|
||||
@ -92,8 +85,6 @@ 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;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { 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,7 +44,6 @@ const FileThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
@ -93,40 +92,6 @@ 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,13 +1,7 @@
|
||||
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 { 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 { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
@ -19,16 +13,11 @@ 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';
|
||||
@ -49,35 +38,24 @@ 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, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument();
|
||||
const { document: mergedPdfDocument } = usePageDocument();
|
||||
|
||||
|
||||
// UI state management
|
||||
const {
|
||||
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
|
||||
setSelectionMode, setSelectedPageIds, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading,
|
||||
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
|
||||
togglePage, toggleSelectAll, animateReorder
|
||||
} = usePageEditorState();
|
||||
|
||||
@ -146,12 +124,6 @@ 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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
@ -9,9 +8,7 @@ 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
|
||||
@ -46,7 +43,6 @@ interface PageEditorControlsProps {
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
onClosePdf,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
@ -54,12 +50,7 @@ const PageEditorControls = ({
|
||||
onRotate,
|
||||
onDelete,
|
||||
onSplit,
|
||||
onSplitAll,
|
||||
onPageBreak,
|
||||
onPageBreakAll,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
|
@ -52,16 +52,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
pageRefs,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onAnimateReorder,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
onSetMovingPage,
|
||||
onDeletePage,
|
||||
createRotateCommand,
|
||||
createDeleteCommand,
|
||||
createSplitCommand,
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
}: PageThumbnailProps) => {
|
||||
@ -172,7 +169,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: ({ source }) => {}
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
|
@ -68,7 +68,6 @@ 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[] => {
|
||||
@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook {
|
||||
});
|
||||
}
|
||||
|
||||
totalPageCount = pages.length;
|
||||
|
||||
if (pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ 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;
|
||||
@ -13,6 +15,7 @@ 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');
|
||||
@ -24,6 +27,12 @@ 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' }} />
|
||||
@ -31,18 +40,21 @@ 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"
|
||||
onClick={handleClick}
|
||||
aria-label={t("quickAccess.allTools", "All Tools")}
|
||||
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,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -15,7 +15,6 @@ 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);
|
||||
|
||||
@ -36,7 +35,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
||||
}
|
||||
|
||||
// Start transition animation
|
||||
setIsChanging(true);
|
||||
setPendingLanguage(value);
|
||||
|
||||
// Simulate processing time for smooth transition
|
||||
@ -44,7 +42,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
||||
i18n.changeLanguage(value);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsChanging(false);
|
||||
setPendingLanguage(null);
|
||||
setOpened(false);
|
||||
|
||||
|
@ -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 (error) {
|
||||
} catch {
|
||||
console.info('ℹ️ Local icons not available - using CDN fallback');
|
||||
}
|
||||
|
||||
|
@ -3,10 +3,11 @@ 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';
|
||||
@ -22,6 +23,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
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);
|
||||
@ -36,6 +38,52 @@ 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[] = [
|
||||
{
|
||||
@ -152,27 +200,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<Stack gap="lg" align="center">
|
||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
|
||||
<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>
|
||||
|
||||
{renderNavButton(config, index)}
|
||||
|
||||
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
||||
{index === 1 && (
|
||||
|
@ -29,7 +29,7 @@ export default function RightRail() {
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
@ -16,6 +16,8 @@ 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';
|
||||
|
||||
@ -26,8 +28,9 @@ interface ActiveToolButtonProps {
|
||||
|
||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
||||
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ 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(
|
||||
@ -38,7 +41,6 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
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);
|
||||
@ -71,11 +73,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
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,10 +84,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
@ -95,11 +93,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
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
|
||||
}
|
||||
@ -142,21 +138,26 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
<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'
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Stack, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||
import ToolButton from './toolPicker/ToolButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -40,12 +40,10 @@ 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;
|
||||
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import ToolPicker from './ToolPicker';
|
||||
@ -8,12 +6,11 @@ import ToolRenderer from './ToolRenderer';
|
||||
import ToolSearch from './toolPicker/ToolSearch';
|
||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { Stack, ScrollArea } from '@mantine/core';
|
||||
import { 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;
|
||||
@ -27,7 +24,6 @@ export default function ToolPanel() {
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
handleBackToTools
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
|
||||
|
@ -3,7 +3,6 @@ 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,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
|
||||
import { Stack, PasswordInput, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface WatermarkTypeSettingsProps {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
import { removeEmojis } from "../../../utils/textUtils";
|
||||
|
@ -0,0 +1,24 @@
|
||||
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 React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@ -38,10 +38,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
||||
automationIcon,
|
||||
setAutomationIcon,
|
||||
selectedTools,
|
||||
addTool,
|
||||
removeTool,
|
||||
updateTool,
|
||||
hasUnsavedChanges,
|
||||
canSaveAutomation,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
@ -84,14 +82,6 @@ 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 { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
|
||||
import { 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';
|
||||
|
@ -76,13 +76,13 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
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
|
||||
));
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
@ -32,7 +32,6 @@ 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];
|
||||
@ -87,9 +86,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isValid) {
|
||||
onSave(parameters);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -127,7 +124,6 @@ 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 React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { 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)
|
||||
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true)
|
||||
), [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}></ToolButton>
|
||||
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
|
||||
</div>
|
||||
) : (
|
||||
// Show search input when no tool selected OR when dropdown is opened
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Stack, Text, Checkbox } from "@mantine/core";
|
||||
import { Stack, Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||
|
||||
|
35
frontend/src/components/tools/flatten/FlattenSettings.tsx
Normal file
35
frontend/src/components/tools/flatten/FlattenSettings.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
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,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Stack, Select, Text, Divider } from '@mantine/core';
|
||||
import { Stack, Select, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
||||
|
@ -8,11 +8,7 @@ interface RemoveCertificateSignSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
||||
disabled = false
|
||||
}) => {
|
||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Stack, Text, PasswordInput } from "@mantine/core";
|
||||
import { Stack, PasswordInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";
|
||||
|
||||
|
@ -8,11 +8,7 @@ interface RepairSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RepairSettings: React.FC<RepairSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Text, Anchor } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
@ -28,7 +28,7 @@ const FileStatusIndicator = ({
|
||||
try {
|
||||
const recentFiles = await loadRecentFiles();
|
||||
setHasRecentFiles(recentFiles.length > 0);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setHasRecentFiles(false);
|
||||
}
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
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";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||
import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
|
||||
@ -19,12 +19,16 @@ export function SuggestedToolsSection(): React.ReactElement {
|
||||
{suggestedTools.map((tool) => {
|
||||
const IconComponent = tool.icon;
|
||||
return (
|
||||
<Card
|
||||
<Anchor
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
onClick={tool.onClick}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Card
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={tool.navigate}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
@ -33,6 +37,7 @@ export function SuggestedToolsSection(): React.ReactElement {
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { Text, Stack, Flex, Divider } from '@mantine/core';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { TooltipTip } from '../../../types/tips';
|
||||
|
@ -5,6 +5,7 @@ import { Tooltip } from '../../shared/Tooltip';
|
||||
|
||||
export interface ToolWorkflowTitleProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
tooltip?: {
|
||||
content?: React.ReactNode;
|
||||
tips?: any[];
|
||||
@ -15,10 +16,19 @@ export interface ToolWorkflowTitleProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
|
||||
if (tooltip) {
|
||||
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="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tooltip ? (
|
||||
<Flex justify="center" w="100%">
|
||||
<Tooltip
|
||||
content={tooltip.content}
|
||||
@ -26,27 +36,17 @@ export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
|
||||
header={tooltip.header}
|
||||
sidebarTooltip={true}
|
||||
>
|
||||
<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>
|
||||
{titleContent}
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
) : (
|
||||
titleContent
|
||||
)}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="center" w="100%">
|
||||
<Text fw={500} size="xl" p="md">
|
||||
{title}
|
||||
<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>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Divider mb="sm" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
})}
|
||||
|
||||
{/* Middle Steps */}
|
||||
{config.steps.map((stepConfig, index) =>
|
||||
{config.steps.map((stepConfig) =>
|
||||
steps.create(stepConfig.title, {
|
||||
isVisible: stepConfig.isVisible,
|
||||
isCollapsed: stepConfig.isCollapsed,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
import SubcategoryHeader from './SubcategoryHeader';
|
||||
|
||||
@ -13,7 +12,8 @@ export const renderToolButtons = (
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
showSubcategoryHeader: boolean = true,
|
||||
disableNavigation: boolean = false
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
@ -27,6 +27,7 @@ export const renderToolButtons = (
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
disableNavigation={disableNavigation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -8,11 +8,7 @@ interface SingleLargePageSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
@ -2,6 +2,8 @@ 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 {
|
||||
@ -10,10 +12,13 @@ interface ToolButtonProps {
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
rounded?: boolean;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
|
||||
const isUnavailable = !tool.component && !tool.link;
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
if (isUnavailable) return;
|
||||
if (tool.link) {
|
||||
@ -25,23 +30,15 @@ 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;
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
|
||||
<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 } }}
|
||||
>
|
||||
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}
|
||||
@ -50,7 +47,67 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
|
||||
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}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
@ -8,11 +8,7 @@ interface UnlockPdfFormsSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
||||
disabled = false
|
||||
}) => {
|
||||
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
22
frontend/src/components/tooltips/useAutoRenameTips.ts
Normal file
22
frontend/src/components/tooltips/useAutoRenameTips.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
34
frontend/src/components/tooltips/useFlattenTips.ts
Normal file
34
frontend/src/components/tooltips/useFlattenTips.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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 +1,17 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
||||
import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||
import LastPageIcon from "@mui/icons-material/LastPage";
|
||||
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
||||
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
||||
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||
import { useFileState } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
import { isFileObject } from "../../types/fileContext";
|
||||
import { FileId } from "../../types/file";
|
||||
@ -142,8 +140,6 @@ export interface ViewerProps {
|
||||
}
|
||||
|
||||
const Viewer = ({
|
||||
sidebarsVisible,
|
||||
setSidebarsVisible,
|
||||
onClose,
|
||||
previewFile,
|
||||
}: ViewerProps) => {
|
||||
@ -152,13 +148,7 @@ const Viewer = ({
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const currentFile = useCurrentFile();
|
||||
|
||||
const getCurrentFile = () => currentFile.file;
|
||||
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
|
||||
const clearAllFiles = actions.clearAllFiles;
|
||||
const addFiles = actions.addFiles;
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Tab management for multiple files
|
||||
@ -406,7 +396,7 @@ const Viewer = ({
|
||||
// Start progressive preloading after a short delay
|
||||
setTimeout(() => startProgressivePreload(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPageImages([]);
|
||||
setNumPages(0);
|
||||
|
@ -39,7 +39,6 @@ const DEBUG = process.env.NODE_ENV === 'development';
|
||||
// Inner provider component that has access to IndexedDB
|
||||
function FileContextInner({
|
||||
children,
|
||||
enableUrlSync = true,
|
||||
enablePersistence = true
|
||||
}: FileContextProviderProps) {
|
||||
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
||||
@ -128,20 +127,9 @@ function FileContextInner({
|
||||
}, [indexedDB]);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
// Helper to find FileId from File object
|
||||
const findFileId = useCallback((file: File): FileId | undefined => {
|
||||
return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
|
||||
const storedFile = filesRef.current.get(id);
|
||||
return storedFile &&
|
||||
storedFile.name === file.name &&
|
||||
storedFile.size === file.size &&
|
||||
storedFile.lastModified === file.lastModified;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// File pinning functions - use StirlingFile directly
|
||||
const pinFileWrapper = useCallback((file: StirlingFile) => {
|
||||
baseActions.pinFile(file.fileId);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage, StoredFile } from '../services/fileStorage';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
@ -61,7 +61,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB
|
||||
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
|
@ -103,7 +103,7 @@ const NavigationActionsContext = createContext<NavigationContextActionsValue | u
|
||||
export const NavigationProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
enableUrlSync?: boolean;
|
||||
}> = ({ children, enableUrlSync = true }) => {
|
||||
}> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
|
||||
|
@ -89,6 +89,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
clearToolSelection: () => void;
|
||||
|
||||
// Tool Reset Actions
|
||||
toolResetFunctions: Record<string, () => void>;
|
||||
registerToolReset: (toolId: string, resetFunction: () => void) => void;
|
||||
resetTool: (toolId: string) => void;
|
||||
|
||||
@ -258,6 +259,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
clearToolSelection: () => actions.setSelectedTool(null),
|
||||
|
||||
// Tool Reset Actions
|
||||
toolResetFunctions,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
|
||||
|
@ -13,8 +13,7 @@ import {
|
||||
import { FileId, FileMetadata } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
||||
import { buildQuickKeySet } from './fileSelectors';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -407,7 +406,6 @@ export async function consumeFiles(
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||
}
|
||||
@ -467,7 +465,6 @@ export async function undoConsumeFiles(
|
||||
inputFiles: File[],
|
||||
inputStirlingFileStubs: StirlingFileStub[],
|
||||
outputFileIds: FileId[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
@ -507,7 +504,6 @@ export async function undoConsumeFiles(
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||
|
||||
} catch (error) {
|
||||
// Rollback filesRef to previous state
|
||||
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||
|
@ -166,9 +166,9 @@ export function useFileContext() {
|
||||
addFiles: actions.addFiles,
|
||||
consumeFiles: actions.consumeFiles,
|
||||
undoConsumeFiles: actions.undoConsumeFiles,
|
||||
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
|
||||
|
||||
// File ID lookup
|
||||
findFileId: (file: File) => {
|
||||
|
@ -50,7 +50,7 @@ export class FileLifecycleManager {
|
||||
this.blobUrls.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
});
|
||||
@ -134,7 +134,7 @@ export class FileLifecycleManager {
|
||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.thumbnailUrl);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
@ -142,18 +142,18 @@ export class FileLifecycleManager {
|
||||
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.blobUrl);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up processed file thumbnails
|
||||
if (record.processedFile?.pages) {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage) => {
|
||||
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(page.thumbnail);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
@ -12,9 +12,11 @@ import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import Repair from "../tools/Repair";
|
||||
import AutoRename from "../tools/AutoRename";
|
||||
import SingleLargePage from "../tools/SingleLargePage";
|
||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||
import Flatten from "../tools/Flatten";
|
||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@ -28,6 +30,8 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
|
||||
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
|
||||
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
@ -39,6 +43,7 @@ import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/Add
|
||||
import OCRSettings from "../components/tools/ocr/OCRSettings";
|
||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import { ToolId } from "../types/toolId";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
@ -198,10 +203,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
flatten: {
|
||||
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.flatten.title", "Flatten"),
|
||||
component: null,
|
||||
component: Flatten,
|
||||
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["flatten"],
|
||||
operationConfig: flattenOperationConfig,
|
||||
settingsComponent: FlattenSettings,
|
||||
},
|
||||
"unlock-pdf-forms": {
|
||||
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@ -355,6 +364,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
urlPath: '/pdf-to-single-page',
|
||||
endpoints: ["pdf-to-single-page"],
|
||||
operationConfig: singleLargePageOperationConfig,
|
||||
},
|
||||
@ -464,7 +474,10 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
"auto-rename-pdf-file": {
|
||||
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.auto-rename.title", "Auto Rename PDF File"),
|
||||
component: null,
|
||||
component: AutoRename,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
operationConfig: autoRenameOperationConfig,
|
||||
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
@ -681,6 +694,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
urlPath: '/ocr-pdf',
|
||||
operationConfig: ocrOperationConfig,
|
||||
settingsComponent: OCRSettings,
|
||||
},
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi, beforeEach, MockedFunction } from 'vitest';
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAddPasswordOperation } from './useAddPasswordOperation';
|
||||
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
|
||||
import type { AddPasswordFullParameters } from './useAddPasswordParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
|
@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AutoRenameParameters, defaultParameters } from './useAutoRenameParameters';
|
||||
|
||||
export const getFormData = ((parameters: AutoRenameParameters) =>
|
||||
Object.entries(parameters).map(([key, value]) =>
|
||||
[key, value.toString()]
|
||||
) as string[][]
|
||||
);
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildAutoRenameFormData = (parameters: AutoRenameParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// Add all permission parameters
|
||||
getFormData(parameters).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const autoRenameOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildAutoRenameFormData,
|
||||
operationType: 'autoRename',
|
||||
endpoint: '/api/v1/misc/auto-rename',
|
||||
filePrefix: 'autoRename_',
|
||||
preserveBackendFilename: true, // Use filename from backend response headers
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useAutoRenameOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation({
|
||||
...autoRenameOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('auto-rename.error.failed', 'An error occurred while auto-renaming the PDF.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface AutoRenameParameters extends BaseParameters {
|
||||
useFirstTextAsFallback: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: AutoRenameParameters = {
|
||||
useFirstTextAsFallback: false,
|
||||
};
|
||||
|
||||
export type AutoRenameParametersHook = BaseParametersHook<AutoRenameParameters>;
|
||||
|
||||
export const useAutoRenameParameters = (): AutoRenameParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'auto-rename',
|
||||
});
|
||||
};
|
@ -3,7 +3,6 @@ import { useCallback } from 'react';
|
||||
import { executeAutomationSequence } from '../../../utils/automationExecutor';
|
||||
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
|
||||
import { AutomateParameters } from '../../../types/automation';
|
||||
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
||||
|
||||
export function useAutomateOperation() {
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
|
@ -46,7 +46,7 @@ export function useSavedAutomations() {
|
||||
const { automationStorage } = await import('../../../services/automationStorage');
|
||||
|
||||
// Map suggested automation icons to MUI icon keys
|
||||
const getIconKey = (suggestedIcon: {id: string}): string => {
|
||||
const getIconKey = (_suggestedIcon: {id: string}): string => {
|
||||
// Check the automation ID or name to determine the appropriate icon
|
||||
switch (suggestedAutomation.id) {
|
||||
case 'secure-pdf-ingestion':
|
||||
|
@ -6,7 +6,6 @@ import { SuggestedAutomation } from '../../../types/automation';
|
||||
|
||||
// Create icon components
|
||||
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
|
||||
const TextFieldsIcon = () => React.createElement(LocalIcon, { icon: 'text-fields', width: '1.5rem', height: '1.5rem' });
|
||||
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
|
||||
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { CompressParameters, defaultParameters } from './useCompressParameters';
|
||||
|
||||
|
@ -2,9 +2,8 @@ import { useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
|
@ -2,7 +2,6 @@ import {
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS,
|
||||
TO_FORMAT_OPTIONS,
|
||||
CONVERSION_MATRIX,
|
||||
type ColorType,
|
||||
type OutputOption,
|
||||
|
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useConvertParameters } from './useConvertParameters';
|
||||
|
||||
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
33
frontend/src/hooks/tools/flatten/useFlattenOperation.ts
Normal file
33
frontend/src/hooks/tools/flatten/useFlattenOperation.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { FlattenParameters, defaultParameters } from './useFlattenParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildFlattenFormData = (parameters: FlattenParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('flattenOnlyForms', parameters.flattenOnlyForms.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const flattenOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildFlattenFormData,
|
||||
operationType: 'flatten',
|
||||
endpoint: '/api/v1/misc/flatten',
|
||||
filePrefix: 'flattened_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useFlattenOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<FlattenParameters>({
|
||||
...flattenOperationConfig,
|
||||
filePrefix: t('flatten.filenamePrefix', 'flattened') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.'))
|
||||
});
|
||||
};
|
19
frontend/src/hooks/tools/flatten/useFlattenParameters.ts
Normal file
19
frontend/src/hooks/tools/flatten/useFlattenParameters.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface FlattenParameters extends BaseParameters {
|
||||
flattenOnlyForms: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: FlattenParameters = {
|
||||
flattenOnlyForms: false,
|
||||
};
|
||||
|
||||
export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
|
||||
|
||||
export const useFlattenParameters = (): FlattenParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'flatten',
|
||||
});
|
||||
};
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
|
||||
export const buildRemoveCertificateSignFormData = (_parameters: RemoveCertificateSignParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RepairParameters, defaultParameters } from './useRepairParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRepairFormData = (parameters: RepairParameters, file: File): FormData => {
|
||||
export const buildRepairFormData = (_parameters: RepairParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
|
@ -8,6 +8,7 @@ export interface ApiCallsConfig<TParams = void> {
|
||||
buildFormData: (params: TParams, file: File) => FormData;
|
||||
filePrefix: string;
|
||||
responseHandler?: ResponseHandler;
|
||||
preserveBackendFilename?: boolean;
|
||||
}
|
||||
|
||||
export const useToolApiCalls = <TParams = void>() => {
|
||||
@ -46,7 +47,8 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
response.data,
|
||||
[file],
|
||||
config.filePrefix,
|
||||
config.responseHandler
|
||||
config.responseHandler,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
processedFiles.push(...responseFiles);
|
||||
|
||||
|
@ -33,6 +33,13 @@ interface BaseToolOperationConfig<TParams> {
|
||||
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
||||
filePrefix: string;
|
||||
|
||||
/**
|
||||
* Whether to preserve the filename provided by the backend in response headers.
|
||||
* When true, ignores filePrefix and uses the filename from Content-Disposition header.
|
||||
* Useful for tools like auto-rename where the backend determines the final filename.
|
||||
*/
|
||||
preserveBackendFilename?: boolean;
|
||||
|
||||
/** How to handle API responses (e.g., ZIP extraction, single file response) */
|
||||
responseHandler?: ResponseHandler;
|
||||
|
||||
@ -128,7 +135,7 @@ export const useToolOperation = <TParams>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
@ -178,7 +185,8 @@ export const useToolOperation = <TParams>(
|
||||
endpoint: config.endpoint,
|
||||
buildFormData: config.buildFormData,
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler
|
||||
responseHandler: config.responseHandler,
|
||||
preserveBackendFilename: config.preserveBackendFilename
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
params,
|
||||
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildSingleLargePageFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
|
||||
export const buildSingleLargePageFormData = (_parameters: SingleLargePageParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
|
@ -71,7 +71,7 @@ export const useSplitOperation = () => {
|
||||
|
||||
// Custom response handler that extracts ZIP files
|
||||
// Can't add to exported config because it requires access to the zip code so must be part of the hook
|
||||
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||
const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise<File[]> => {
|
||||
// Split operations return ZIP files with multiple PDF pages
|
||||
return await extractZipFiles(blob);
|
||||
}, [extractZipFiles]);
|
||||
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
|
||||
export const buildUnlockPdfFormsFormData = (_parameters: UnlockPdfFormsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
|
@ -184,11 +184,6 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
|
||||
// Force show after initialization
|
||||
setTimeout(() => {
|
||||
window.CookieConsent.show();
|
||||
|
||||
// Debug: Check if modal elements exist
|
||||
const ccMain = document.getElementById('cc-main');
|
||||
const consentModal = document.querySelector('.cm-wrapper');
|
||||
|
||||
}, 200);
|
||||
|
||||
} catch (error) {
|
||||
|
@ -105,7 +105,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const endpointsKey = endpoints.join(',');
|
||||
fetchAllEndpointStatuses();
|
||||
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
||||
|
||||
|
@ -135,7 +135,7 @@ export function useEnhancedProcessedFiles(
|
||||
updatedFiles.set(file, processed);
|
||||
hasNewFiles = true;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore errors in completion check
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
export const useFileManager = () => {
|
||||
|
@ -4,20 +4,6 @@ import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
import { FileId } from "../types/fileContext";
|
||||
|
||||
/**
|
||||
* Calculate optimal scale for thumbnail generation
|
||||
* Ensures high quality while preventing oversized renders
|
||||
*/
|
||||
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
|
||||
const maxWidth = 400; // Max thumbnail width
|
||||
const maxHeight = 600; // Max thumbnail height
|
||||
|
||||
const scaleX = maxWidth / pageViewport.width;
|
||||
const scaleY = maxHeight / pageViewport.height;
|
||||
|
||||
// Don't upscale, only downscale if needed
|
||||
return Math.min(scaleX, scaleY, 1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for IndexedDB-aware thumbnail loading
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
import { StirlingFile } from '../types/fileContext';
|
||||
|
||||
|
51
frontend/src/hooks/useSidebarNavigation.ts
Normal file
51
frontend/src/hooks/useSidebarNavigation.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useToolNavigation } from './useToolNavigation';
|
||||
import { useToolManagement } from './useToolManagement';
|
||||
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
|
||||
|
||||
export interface SidebarNavigationProps {
|
||||
/** Full URL for the navigation (for href attribute) */
|
||||
href: string;
|
||||
/** Click handler that maintains SPA behavior */
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides URL and navigation handlers for sidebar navigation buttons
|
||||
* Supports special routes like home ('/') and specific tool routes
|
||||
*/
|
||||
export function useSidebarNavigation(): {
|
||||
getHomeNavigation: () => SidebarNavigationProps;
|
||||
getToolNavigation: (toolId: string) => SidebarNavigationProps | null;
|
||||
} {
|
||||
const { getToolNavigation: getToolNavProps } = useToolNavigation();
|
||||
const { getSelectedTool } = useToolManagement();
|
||||
|
||||
const defaultNavClick = useCallback((e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, () => {
|
||||
// SPA navigation will be handled by the calling component
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getHomeNavigation = useCallback((): SidebarNavigationProps => {
|
||||
const href = '/'; // SSR-safe relative path
|
||||
return { href, onClick: defaultNavClick };
|
||||
}, [defaultNavClick]);
|
||||
|
||||
const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => {
|
||||
// Handle special nav sections that aren't tools
|
||||
if (toolId === 'read') return { href: '/read', onClick: defaultNavClick };
|
||||
if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick };
|
||||
|
||||
const tool = getSelectedTool(toolId);
|
||||
if (!tool) return null;
|
||||
|
||||
// Delegate to useToolNavigation for true tools
|
||||
return getToolNavProps(toolId, tool);
|
||||
}, [getToolNavProps, getSelectedTool, defaultNavClick]);
|
||||
|
||||
return {
|
||||
getHomeNavigation,
|
||||
getToolNavigation
|
||||
};
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
|
||||
import { useNavigationState } from '../contexts/NavigationContext';
|
||||
import { useToolNavigation } from './useToolNavigation';
|
||||
import { useToolManagement } from './useToolManagement';
|
||||
import { ToolId } from '../types/toolId';
|
||||
|
||||
// Material UI Icons
|
||||
@ -13,10 +15,11 @@ export interface SuggestedTool {
|
||||
id: ToolId;
|
||||
title: string;
|
||||
icon: React.ComponentType<any>;
|
||||
navigate: () => void;
|
||||
href: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'href' | 'onClick'>[] = [
|
||||
{
|
||||
id: 'compress',
|
||||
title: 'Compress',
|
||||
@ -45,17 +48,31 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||
];
|
||||
|
||||
export function useSuggestedTools(): SuggestedTool[] {
|
||||
const { actions } = useNavigationActions();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const { getSelectedTool } = useToolManagement();
|
||||
|
||||
return useMemo(() => {
|
||||
// Filter out the current tool
|
||||
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedTool);
|
||||
|
||||
// Add navigation function to each tool
|
||||
return filteredTools.map(tool => ({
|
||||
// Add navigation props to each tool
|
||||
return filteredTools.map(tool => {
|
||||
const toolRegistryEntry = getSelectedTool(tool.id);
|
||||
if (!toolRegistryEntry) {
|
||||
// Fallback for tools not in registry
|
||||
return {
|
||||
...tool,
|
||||
navigate: () => actions.setSelectedTool(tool.id)
|
||||
}));
|
||||
}, [selectedTool, actions]);
|
||||
href: `/${tool.id}`,
|
||||
onClick: (e: React.MouseEvent) => { e.preventDefault(); }
|
||||
};
|
||||
}
|
||||
|
||||
const navProps = getToolNavigation(tool.id, toolRegistryEntry);
|
||||
return {
|
||||
...tool,
|
||||
...navProps
|
||||
};
|
||||
});
|
||||
}, [selectedTool, getToolNavigation, getSelectedTool]);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
import { FileId } from '../types/file';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
||||
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
@ -20,15 +20,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const baseRegistry = useFlatToolRegistry();
|
||||
const registryDerivedEndpoints = useMemo(() => {
|
||||
const endpointsByTool: Record<string, string[]> = {};
|
||||
Object.entries(baseRegistry).forEach(([key, entry]) => {
|
||||
if (entry.endpoints && entry.endpoints.length > 0) {
|
||||
endpointsByTool[key] = entry.endpoints;
|
||||
}
|
||||
});
|
||||
return endpointsByTool;
|
||||
}, [baseRegistry]);
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
45
frontend/src/hooks/useToolNavigation.ts
Normal file
45
frontend/src/hooks/useToolNavigation.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
|
||||
|
||||
export interface ToolNavigationProps {
|
||||
/** Full URL for the tool (for href attribute) */
|
||||
href: string;
|
||||
/** Click handler that maintains SPA behavior */
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides URL and navigation handlers for tools
|
||||
* Enables right-click "Open in New Tab" while maintaining SPA behavior for regular clicks
|
||||
*/
|
||||
export function useToolNavigation(): {
|
||||
getToolNavigation: (toolId: string, tool: ToolRegistryEntry) => ToolNavigationProps;
|
||||
} {
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
|
||||
const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => {
|
||||
// Generate SSR-safe relative path
|
||||
const path = getToolUrlPath(toolId, tool);
|
||||
const href = path; // Relative path, no window.location needed
|
||||
|
||||
// Click handler that maintains SPA behavior
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, () => {
|
||||
// Handle external links normally
|
||||
if (tool.link) {
|
||||
window.open(tool.link, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use SPA navigation for internal tools
|
||||
handleToolSelect(toolId);
|
||||
});
|
||||
};
|
||||
|
||||
return { href, onClick };
|
||||
}, [handleToolSelect]);
|
||||
|
||||
return { getToolNavigation };
|
||||
}
|
@ -10,8 +10,8 @@ type ToolParameterValues = Record<string, any>;
|
||||
* Register tool parameters and get current values
|
||||
*/
|
||||
export function useToolParameters(
|
||||
toolName: string,
|
||||
parameters: Record<string, any>
|
||||
_toolName: string,
|
||||
_parameters: Record<string, any>
|
||||
): [ToolParameterValues, (updates: Partial<ToolParameterValues>) => void] {
|
||||
|
||||
// Return empty values and noop updater
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user