mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
Merge remote-tracking branch 'origin/V2' into feature/v2/filehistory
This commit is contained in:
commit
921b0a07b0
@ -24,7 +24,7 @@ indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{*.js,*.jsx,*.ts,*.tsx}]
|
||||
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
|
||||
indent_size = 2
|
||||
|
||||
[*.css]
|
||||
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -147,6 +147,8 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
run: cd frontend && npm run build
|
||||
- name: Run frontend tests
|
||||
|
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@ -19,5 +19,6 @@
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
||||
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
|
||||
]
|
||||
}
|
||||
|
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
|
||||
|
42
frontend/eslint.config.mjs
Normal file
42
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,42 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
ignores: [
|
||||
"dist", // Contains 3rd party code
|
||||
"public", // Contains 3rd party code
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-undef": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
|
||||
allowInterfaces: 'with-single-extends',
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"args": "all", // All function args must be used (or explicitly ignored)
|
||||
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
|
||||
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
934
frontend/package-lock.json
generated
934
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@
|
||||
"predev": "npm run generate-icons",
|
||||
"dev": "npx tsc --noEmit && vite",
|
||||
"prebuild": "npm run generate-icons",
|
||||
"lint": "npx eslint",
|
||||
"build": "npx tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@ -72,6 +73,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@iconify-json/material-symbols": "^1.2.33",
|
||||
"@iconify/utils": "^3.0.1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
@ -80,6 +82,7 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsdom": "^23.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
@ -87,7 +90,8 @@
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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' });
|
||||
}
|
||||
@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) {
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/);
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
|
||||
if (match && licenseUrls[match[1]]) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
|
@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
|
||||
// Loading component for i18next suspense
|
||||
const LoadingFallback = () => (
|
||||
<div
|
||||
|
@ -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 storeFileWithId = 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 } from '../../types/file';
|
||||
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => 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 activeFileRecords = useMemo(() => selectors.getFileRecords(), [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
|
||||
@ -92,10 +75,10 @@ const FileEditor = ({
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeFileRecords directly - no conversion needed
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
// Helper to convert FileRecord to FileThumbnail format
|
||||
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||
const recordToFileItem = useCallback((record: any) => {
|
||||
const file = selectors.getFile(record.id);
|
||||
if (!file) return null;
|
||||
@ -162,29 +145,9 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
@ -214,25 +177,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`);
|
||||
@ -253,27 +197,10 @@ const FileEditor = ({
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
|
||||
}, [activeFileRecords, setSelectedFiles]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
||||
|
||||
const closeAllFiles = useCallback(() => {
|
||||
if (activeFileRecords.length === 0) return;
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
const allFileIds = activeFileRecords.map(record => record.id);
|
||||
removeFiles(allFileIds, false); // false = keep in storage
|
||||
|
||||
// Clear selections
|
||||
setSelectedFiles([]);
|
||||
}, [activeFileRecords, removeFiles, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
||||
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
@ -303,21 +230,12 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
|
||||
}, [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[]) => {
|
||||
const currentIds = activeFileRecords.map(r => r.id);
|
||||
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||
@ -369,71 +287,34 @@ const FileEditor = ({
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeFileRecords, reorderFiles, setStatus]);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
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
|
||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
setSelectedFiles([fileId]);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
||||
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const recordsToMerge = activeFileRecords.slice(startIndex);
|
||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [activeFileRecords, selectors, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: FileId) => {
|
||||
const file = selectors.getFile(fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file);
|
||||
}
|
||||
}, [selectors, onOpenPageEditor]);
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
@ -468,7 +349,7 @@ const FileEditor = ({
|
||||
<Box p="md" pt="xl">
|
||||
|
||||
|
||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
@ -476,7 +357,7 @@ const FileEditor = ({
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
@ -523,7 +404,7 @@ const FileEditor = ({
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{activeFileRecords.map((record, index) => {
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
@ -532,7 +413,7 @@ const FileEditor = ({
|
||||
key={record.id}
|
||||
file={fileItem}
|
||||
index={index}
|
||||
totalFiles={activeFileRecords.length}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
|
@ -45,7 +45,6 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
@ -62,8 +61,8 @@ const FileEditorThumbnail = ({
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
||||
}, [activeFiles, file.name, file.size]);
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Get file record to access tool history
|
||||
|
@ -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,
|
||||
@ -61,8 +60,8 @@ const FileThumbnail = ({
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
||||
}, [activeFiles, file.name, file.size]);
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
@ -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 = () => {
|
||||
|
@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get primary file record outside useMemo to track processedFile changes
|
||||
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
|
||||
const processedFilePages = primaryFileRecord?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
|
||||
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
// Compute merged document with stable signature (prevents infinite loops)
|
||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
||||
|
||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||
if (!primaryFileRecord) {
|
||||
if (!primaryStirlingFileStub) {
|
||||
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
activeFileIds.length === 1
|
||||
? (primaryFileRecord.name ?? 'document.pdf')
|
||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||
: activeFileIds
|
||||
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.join(' + ');
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const originalFileIds: FileId[] = [];
|
||||
|
||||
activeFileIds.forEach(fileId => {
|
||||
const record = selectors.getFileRecord(fileId);
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record?.insertAfterPageId !== undefined) {
|
||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||
insertionMap.set(record.insertAfterPageId, []);
|
||||
@ -68,16 +68,15 @@ export function usePageDocument(): PageDocumentHook {
|
||||
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[] = [];
|
||||
let totalPageCount = 0;
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
const fileRecord = selectors.getFileRecord(fileId);
|
||||
if (!fileRecord) {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processedFile = fileRecord.processedFile;
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||
@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook {
|
||||
});
|
||||
}
|
||||
|
||||
totalPageCount = pages.length;
|
||||
|
||||
if (pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@ -159,7 +156,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
|
@ -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' : ''}
|
||||
>
|
||||
|
@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { StirlingFileStub } from "../../types/fileContext";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
record?: FileRecord;
|
||||
record?: StirlingFileStub;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
@ -25,7 +25,7 @@ interface FileCardProps {
|
||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
||||
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
@ -1,18 +1,18 @@
|
||||
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";
|
||||
import FileCard from "./FileCard";
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { StirlingFileStub } from "../../types/fileContext";
|
||||
import { FileId } from "../../types/file";
|
||||
|
||||
interface FileGridProps {
|
||||
files: Array<{ file: File; record?: FileRecord }>;
|
||||
files: Array<{ file: File; record?: StirlingFileStub }>;
|
||||
onRemove?: (index: number) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onSelect?: (fileId: FileId) => void;
|
||||
selectedFiles?: FileId[];
|
||||
showSearch?: boolean;
|
||||
@ -123,9 +123,17 @@ const FileGrid = ({
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles.map((item, idx) => {
|
||||
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
|
||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
||||
{displayFiles
|
||||
.filter(item => {
|
||||
if (!item.record?.id) {
|
||||
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const fileId = item.record!.id; // Safe to assert after filter
|
||||
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
|
@ -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';
|
||||
@ -17,12 +18,12 @@ import {
|
||||
getActiveNavButton,
|
||||
} from './quickAccessBar/QuickAccessBar';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
}, ref) => {
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@ -37,6 +38,52 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
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[] = [
|
||||
{
|
||||
@ -153,27 +200,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
<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,12 +29,11 @@ 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();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const fileRecords = selectors.getFileRecords();
|
||||
|
||||
// Compute selection state and total items
|
||||
const getSelectionState = useCallback(() => {
|
||||
|
@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
try { ro.disconnect(); } catch {}
|
||||
try { mo.disconnect(); } catch {}
|
||||
try { ro.disconnect(); } catch { /* Ignore errors */ }
|
||||
try { mo.disconnect(); } catch { /* Ignore errors */ }
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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);
|
||||
}
|
||||
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 { Stack, Text, Checkbox } from "@mantine/core";
|
||||
import { Stack, Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||
|
||||
|
@ -22,13 +22,13 @@ import {
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS
|
||||
} from "../../../constants/convertConstants";
|
||||
import { FileId } from "../../../types/file";
|
||||
import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
interface ConvertSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@ -129,7 +129,7 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
const filterFilesByExtension = (extension: string) => {
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
|
||||
return files.filter(file => {
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
|
||||
@ -143,21 +143,8 @@ const ConvertSettings = ({
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileSelection = (files: File[]) => {
|
||||
// Map File objects to their actual IDs in FileContext
|
||||
const fileIds = files.map(file => {
|
||||
// Find the file ID by matching file properties
|
||||
const fileRecord = state.files.ids
|
||||
.map(id => selectors.getFileRecord(id))
|
||||
.find(record =>
|
||||
record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified
|
||||
);
|
||||
return fileRecord?.id;
|
||||
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
|
||||
|
||||
const updateFileSelection = (files: StirlingFile[]) => {
|
||||
const fileIds = files.map(file => file.fileId);
|
||||
setSelectedFiles(fileIds);
|
||||
};
|
||||
|
||||
|
@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
interface ConvertToPdfaSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
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';
|
||||
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||
import { useAllFiles } from "../../../contexts/FileContext";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: File[];
|
||||
selectedFiles?: StirlingFile[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const { files: workbenchFiles } = useAllFiles();
|
||||
const { files: stirlingFileStubs } = useAllFiles();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
|
||||
@ -27,7 +28,7 @@ const FileStatusIndicator = ({
|
||||
try {
|
||||
const recentFiles = await loadRecentFiles();
|
||||
setHasRecentFiles(recentFiles.length > 0);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setHasRecentFiles(false);
|
||||
}
|
||||
};
|
||||
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
|
||||
}
|
||||
|
||||
// Check if there are no files in the workbench
|
||||
if (workbenchFiles.length === 0) {
|
||||
if (stirlingFileStubs.length === 0) {
|
||||
// If no recent files, show upload button
|
||||
if (!hasRecentFiles) {
|
||||
return (
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileStatusIndicator from './FileStatusIndicator';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesToolStepProps {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
placeholder?: string;
|
||||
|
@ -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,10 +1,8 @@
|
||||
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';
|
||||
|
||||
export interface SuggestedToolsSectionProps {}
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const suggestedTools = useSuggestedTools();
|
||||
@ -21,20 +19,25 @@ export function SuggestedToolsSection(): React.ReactElement {
|
||||
{suggestedTools.map((tool) => {
|
||||
const IconComponent = tool.icon;
|
||||
return (
|
||||
<Card
|
||||
<Anchor
|
||||
key={tool.id}
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={tool.navigate}
|
||||
href={tool.href}
|
||||
onClick={tool.onClick}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
<Text size="sm" fw={500}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
<Text size="sm" fw={500}>
|
||||
{tool.title}
|
||||
</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';
|
||||
|
@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||
import OperationButton from './OperationButton';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
@ -80,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';
|
||||
|
||||
|
@ -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 {
|
||||
@ -14,6 +16,8 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
||||
const isUnavailable = !tool.component && !tool.link;
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
if (isUnavailable) return;
|
||||
if (tool.link) {
|
||||
@ -25,32 +29,84 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
// Get navigation props for URL support
|
||||
const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null;
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
: tool.description;
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleExternalClick = (e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||
};
|
||||
|
||||
const buttonElement = navProps ? (
|
||||
// For internal tools with URLs, render Button as an anchor for proper link behavior
|
||||
<Button
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={navProps.onClick}
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
) : tool.link && !isUnavailable ? (
|
||||
// For external links, render Button as an anchor with proper href
|
||||
<Button
|
||||
component="a"
|
||||
href={tool.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleExternalClick}
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
) : (
|
||||
// For unavailable tools, use regular button
|
||||
<Button
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
onClick={() => handleClick(id)}
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
aria-disabled={isUnavailable}
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
|
||||
<Button
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
onClick={()=> handleClick(id)}
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
aria-disabled={isUnavailable}
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
|
||||
>
|
||||
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
</Button>
|
||||
{buttonElement}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
@ -126,7 +126,7 @@ const ToolSearch = ({
|
||||
key={id}
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
onToolSelect && onToolSelect(id);
|
||||
onToolSelect?.(id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
|
||||
|
@ -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 (
|
||||
|
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,20 +1,19 @@
|
||||
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";
|
||||
|
||||
|
||||
@ -141,8 +140,6 @@ export interface ViewerProps {
|
||||
}
|
||||
|
||||
const Viewer = ({
|
||||
sidebarsVisible,
|
||||
setSidebarsVisible,
|
||||
onClose,
|
||||
previewFile,
|
||||
}: ViewerProps) => {
|
||||
@ -151,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
|
||||
@ -201,7 +192,7 @@ const Viewer = ({
|
||||
const effectiveFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
// Validate the preview file
|
||||
if (!(previewFile instanceof File)) {
|
||||
if (!isFileObject(previewFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -405,7 +396,7 @@ const Viewer = ({
|
||||
// Start progressive preloading after a short delay
|
||||
setTimeout(() => startProgressivePreload(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPageImages([]);
|
||||
setNumPages(0);
|
||||
|
@ -19,7 +19,10 @@ import {
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue,
|
||||
FileContextActions,
|
||||
FileRecord
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
|
||||
import { FileLifecycleManager } from './file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -37,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);
|
||||
@ -79,7 +80,7 @@ function FileContextInner({
|
||||
}
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<File[]> => {
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
@ -98,15 +99,15 @@ function FileContextInner({
|
||||
}));
|
||||
}
|
||||
|
||||
return addedFilesWithIds.map(({ file }) => file);
|
||||
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, [indexedDB, enablePersistence]);
|
||||
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
return result.map(({ file }) => file);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => {
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
@ -114,7 +115,7 @@ function FileContextInner({
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result.map(({ file }) => file);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
// Action creators
|
||||
@ -122,42 +123,21 @@ function FileContextInner({
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
|
||||
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
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);
|
||||
}, [baseActions]);
|
||||
|
||||
// File-to-ID wrapper functions for pinning
|
||||
const pinFileWrapper = useCallback((file: File) => {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
baseActions.pinFile(fileId);
|
||||
} else {
|
||||
console.warn('File not found for pinning:', file.name);
|
||||
}
|
||||
}, [baseActions, findFileId]);
|
||||
|
||||
const unpinFileWrapper = useCallback((file: File) => {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
baseActions.unpinFile(fileId);
|
||||
} else {
|
||||
console.warn('File not found for unpinning:', file.name);
|
||||
}
|
||||
}, [baseActions, findFileId]);
|
||||
const unpinFileWrapper = useCallback((file: StirlingFile) => {
|
||||
baseActions.unpinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
// Complete actions object
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
@ -178,8 +158,8 @@ function FileContextInner({
|
||||
}
|
||||
}
|
||||
},
|
||||
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
||||
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
||||
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
|
||||
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
|
||||
reorderFiles: (orderedFileIds: FileId[]) => {
|
||||
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
||||
},
|
||||
@ -303,7 +283,7 @@ export {
|
||||
useFileSelection,
|
||||
useFileManagement,
|
||||
useFileUI,
|
||||
useFileRecord,
|
||||
useStirlingFileStub,
|
||||
useAllFiles,
|
||||
useSelectedFiles,
|
||||
// Primary API hooks for tools
|
||||
|
@ -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';
|
||||
import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
|
||||
|
@ -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';
|
||||
@ -64,7 +64,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,
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
|
||||
import {
|
||||
FileContextState,
|
||||
FileContextAction,
|
||||
FileRecord
|
||||
StirlingFileStub
|
||||
} from '../../types/fileContext';
|
||||
|
||||
// Initial state
|
||||
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
|
||||
function processFileSwap(
|
||||
state: FileContextState,
|
||||
filesToRemove: FileId[],
|
||||
filesToAdd: FileRecord[]
|
||||
filesToAdd: StirlingFileStub[]
|
||||
): FileContextState {
|
||||
// Only remove unpinned files
|
||||
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||
@ -70,11 +70,11 @@ function processFileSwap(
|
||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
case 'ADD_FILES': {
|
||||
const { fileRecords } = action.payload;
|
||||
const { stirlingFileStubs } = action.payload;
|
||||
const newIds: FileId[] = [];
|
||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||
const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
|
||||
|
||||
fileRecords.forEach(record => {
|
||||
stirlingFileStubs.forEach(record => {
|
||||
// Only add if not already present (dedupe by stable ID)
|
||||
if (!newById[record.id]) {
|
||||
newIds.push(record.id);
|
||||
@ -235,13 +235,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
}
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputFileRecords } = action.payload;
|
||||
return processFileSwap(state, inputFileIds, outputFileRecords);
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case 'UNDO_CONSUME_FILES': {
|
||||
const { inputFileRecords, outputFileIds } = action.payload;
|
||||
return processFileSwap(state, outputFileIds, inputFileRecords);
|
||||
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case 'RESET_CONTEXT': {
|
||||
|
@ -3,19 +3,18 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
FileRecord,
|
||||
StirlingFileStub,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
toFileRecord,
|
||||
toStirlingFileStub,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
} from '../../types/fileContext';
|
||||
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 { extractFileHistory, extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
|
||||
import { buildQuickKeySet } from './fileSelectors';
|
||||
import { extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -110,8 +109,8 @@ export async function addFiles(
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const fileRecords: FileRecord[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
@ -164,7 +163,7 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
// Create record with immediate thumbnail and page metadata
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
@ -204,7 +203,7 @@ export async function addFiles(
|
||||
});
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
@ -225,7 +224,7 @@ export async function addFiles(
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
@ -265,7 +264,7 @@ export async function addFiles(
|
||||
});
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
@ -293,7 +292,7 @@ export async function addFiles(
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
@ -359,7 +358,7 @@ export async function addFiles(
|
||||
});
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
}
|
||||
@ -368,9 +367,9 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (fileRecords.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
@ -386,7 +385,7 @@ export async function addFiles(
|
||||
async function processFilesIntoRecords(
|
||||
files: File[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
@ -405,7 +404,7 @@ async function processFilesIntoRecords(
|
||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
@ -440,10 +439,10 @@ async function processFilesIntoRecords(
|
||||
* Helper function to persist files to IndexedDB
|
||||
*/
|
||||
async function persistFilesToIndexedDB(
|
||||
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||
): Promise<void> {
|
||||
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
||||
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
@ -458,7 +457,6 @@ async function persistFilesToIndexedDB(
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; markFileAsProcessed: (fileId: FileId) => Promise<boolean> } | null
|
||||
@ -466,37 +464,11 @@ export async function consumeFiles(
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
|
||||
// Process output files with thumbnails and metadata
|
||||
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
|
||||
// Mark input files as processed in IndexedDB (no longer leaf nodes)
|
||||
if (indexedDB) {
|
||||
await Promise.all([
|
||||
// Mark input files as processed
|
||||
...inputFileIds.map(async (fileId) => {
|
||||
try {
|
||||
await indexedDB.markFileAsProcessed(fileId);
|
||||
// Update file record to reflect that it's no longer a leaf
|
||||
dispatch({
|
||||
type: 'UPDATE_FILE_RECORD',
|
||||
payload: {
|
||||
id: fileId,
|
||||
updates: { isLeaf: false }
|
||||
}
|
||||
});
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Marked file ${fileId} as processed`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 consumeFiles: Failed to mark file ${fileId} as processed:`, error);
|
||||
}
|
||||
}),
|
||||
// Persist output files to IndexedDB
|
||||
...outputFileRecords.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
})
|
||||
]);
|
||||
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||
}
|
||||
|
||||
// Dispatch the consume action
|
||||
@ -504,21 +476,20 @@ export async function consumeFiles(
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputFileRecords: outputFileRecords.map(({ record }) => record)
|
||||
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||
|
||||
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 outputFileRecords.map(({ fileId }) => fileId);
|
||||
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||
*/
|
||||
async function restoreFilesAndCleanup(
|
||||
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
||||
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
|
||||
fileIdsToRemove: FileId[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
@ -567,18 +538,17 @@ async function restoreFilesAndCleanup(
|
||||
*/
|
||||
export async function undoConsumeFiles(
|
||||
inputFiles: File[],
|
||||
inputFileRecords: FileRecord[],
|
||||
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
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
|
||||
|
||||
// Validate inputs
|
||||
if (inputFiles.length !== inputFileRecords.length) {
|
||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
||||
if (inputFiles.length !== inputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Create a backup of current filesRef state for rollback
|
||||
@ -588,7 +558,7 @@ export async function undoConsumeFiles(
|
||||
// Prepare files to restore
|
||||
const filesToRestore = inputFiles.map((file, index) => ({
|
||||
file,
|
||||
record: inputFileRecords[index]
|
||||
record: inputStirlingFileStubs[index]
|
||||
}));
|
||||
|
||||
// Restore input files and clean up output files
|
||||
@ -603,13 +573,12 @@ export async function undoConsumeFiles(
|
||||
dispatch({
|
||||
type: 'UNDO_CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileRecords,
|
||||
inputStirlingFileStubs,
|
||||
outputFileIds
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||
|
||||
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);
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue
|
||||
} from './contexts';
|
||||
import { FileRecord } from '../../types/fileContext';
|
||||
import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
/**
|
||||
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
|
||||
/**
|
||||
* Hook for current/primary file (first in list)
|
||||
*/
|
||||
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
||||
export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
const primaryFileId = state.files.ids[0];
|
||||
return useMemo(() => ({
|
||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
||||
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
|
||||
}), [primaryFileId, selectors]);
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ export function useFileManagement() {
|
||||
addFiles: actions.addFiles,
|
||||
removeFiles: actions.removeFiles,
|
||||
clearAllFiles: actions.clearAllFiles,
|
||||
updateFileRecord: actions.updateFileRecord,
|
||||
updateStirlingFileStub: actions.updateStirlingFileStub,
|
||||
reorderFiles: actions.reorderFiles
|
||||
}), [actions]);
|
||||
}
|
||||
@ -111,24 +111,24 @@ export function useFileUI() {
|
||||
/**
|
||||
* Hook for specific file by ID (optimized for individual file access)
|
||||
*/
|
||||
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
||||
export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
|
||||
const { selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
file: selectors.getFile(fileId),
|
||||
record: selectors.getFileRecord(fileId)
|
||||
record: selectors.getStirlingFileStub(fileId)
|
||||
}), [fileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getFiles(),
|
||||
records: selectors.getFileRecords(),
|
||||
records: selectors.getStirlingFileStubs(),
|
||||
fileIds: state.files.ids
|
||||
}), [state.files.ids, selectors]);
|
||||
}
|
||||
@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getSelectedFiles(),
|
||||
records: selectors.getSelectedFileRecords(),
|
||||
records: selectors.getSelectedStirlingFileStubs(),
|
||||
fileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
@ -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) => {
|
||||
|
@ -4,9 +4,11 @@
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import {
|
||||
FileRecord,
|
||||
StirlingFileStub,
|
||||
FileContextState,
|
||||
FileContextSelectors
|
||||
FileContextSelectors,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
@ -17,16 +19,24 @@ export function createFileSelectors(
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): FileContextSelectors {
|
||||
return {
|
||||
getFile: (id: FileId) => filesRef.current.get(id),
|
||||
getFile: (id: FileId) => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
},
|
||||
|
||||
getFiles: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
||||
return currentIds
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||
getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
|
||||
|
||||
getFileRecords: (ids?: FileId[]) => {
|
||||
getStirlingFileStubs: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||
},
|
||||
@ -35,11 +45,14 @@ export function createFileSelectors(
|
||||
|
||||
getSelectedFiles: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getSelectedFileRecords: () => {
|
||||
getSelectedStirlingFileStubs: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
@ -52,26 +65,21 @@ export function createFileSelectors(
|
||||
|
||||
getPinnedFiles: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getPinnedFileRecords: () => {
|
||||
getPinnedStirlingFileStubs: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
isFilePinned: (file: File) => {
|
||||
// Find FileId by matching File object properties
|
||||
const fileId = (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;
|
||||
});
|
||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
||||
isFilePinned: (file: StirlingFile) => {
|
||||
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||
},
|
||||
|
||||
// Stable signature for effects - prevents unnecessary re-renders
|
||||
@ -90,9 +98,9 @@ export function createFileSelectors(
|
||||
/**
|
||||
* Helper for building quickKey sets for deduplication
|
||||
*/
|
||||
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
||||
export function buildQuickKeySet(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
Object.values(fileRecords).forEach(record => {
|
||||
Object.values(stirlingFileStubs).forEach(record => {
|
||||
if (record.quickKey) {
|
||||
quickKeys.add(record.quickKey);
|
||||
}
|
||||
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
|
||||
export function getPrimaryFile(
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): { file?: File; record?: FileRecord } {
|
||||
): { file?: File; record?: StirlingFileStub } {
|
||||
const primaryFileId = stateRef.current.files.ids[0];
|
||||
if (!primaryFileId) return {};
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||
import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -166,7 +166,7 @@ export class FileLifecycleManager {
|
||||
/**
|
||||
* Update file record with race condition guards
|
||||
*/
|
||||
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
||||
updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Guard against updating removed files (race condition protection)
|
||||
if (!this.filesRef.current.has(fileId)) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||
|
@ -15,6 +15,7 @@ import Repair from "../tools/Repair";
|
||||
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 +29,7 @@ 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 { 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 +41,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 +201,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 +362,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,
|
||||
},
|
||||
@ -681,6 +689,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 () => {
|
||||
|
@ -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', () => {
|
||||
@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
const malformedFiles: Array<{name: string}> = [
|
||||
{ name: 'valid.pdf' },
|
||||
// @ts-ignore - Testing runtime resilience
|
||||
// @ts-expect-error - Testing runtime resilience
|
||||
{ name: null },
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - Testing runtime resilience
|
||||
{ name: undefined }
|
||||
];
|
||||
|
||||
|
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;
|
||||
|
@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
|
||||
import { BaseToolProps } from '../../../types/tool';
|
||||
import { ToolOperationHook } from './useToolOperation';
|
||||
import { BaseParametersHook } from './useBaseParameters';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
interface BaseToolReturn<TParams> {
|
||||
// File management
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
|
||||
// Tool-specific hooks
|
||||
params: BaseParametersHook<TParams>;
|
||||
|
@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { FileId } from '../../../types/file';
|
||||
import { FileRecord } from '../../../types/fileContext';
|
||||
import { prepareFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@ -105,7 +103,7 @@ export interface ToolOperationHook<TParams = void> {
|
||||
progress: ProcessingProgress | null;
|
||||
|
||||
// Actions
|
||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
||||
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
cancelOperation: () => void;
|
||||
@ -131,7 +129,7 @@ export const useToolOperation = <TParams>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
@ -141,13 +139,13 @@ export const useToolOperation = <TParams>(
|
||||
// Track last operation for undo functionality
|
||||
const lastOperationRef = useRef<{
|
||||
inputFiles: File[];
|
||||
inputFileRecords: FileRecord[];
|
||||
inputStirlingFileStubs: StirlingFileStub[];
|
||||
outputFileIds: FileId[];
|
||||
} | null>(null);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
params: TParams,
|
||||
selectedFiles: File[]
|
||||
selectedFiles: StirlingFile[]
|
||||
): Promise<void> => {
|
||||
// Validation
|
||||
if (selectedFiles.length === 0) {
|
||||
@ -161,9 +159,6 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup operation tracking
|
||||
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
// Reset state
|
||||
actions.setLoading(true);
|
||||
@ -188,8 +183,11 @@ export const useToolOperation = <TParams>(
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
|
||||
// Convert StirlingFile to regular File objects for API processing
|
||||
const validRegularFiles = extractFiles(validFiles);
|
||||
|
||||
switch (config.toolType) {
|
||||
case ToolType.singleFile:
|
||||
case ToolType.singleFile: {
|
||||
// Individual file processing - separate API call per file
|
||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||
endpoint: config.endpoint,
|
||||
@ -200,16 +198,18 @@ export const useToolOperation = <TParams>(
|
||||
processedFiles = await processFiles(
|
||||
params,
|
||||
filesWithHistory,
|
||||
validRegularFiles,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.multiFile:
|
||||
case ToolType.multiFile: {
|
||||
// Multi-file processing - single API call with all files
|
||||
actions.setStatus('Processing files...');
|
||||
const formData = config.buildFormData(params, filesWithHistory);
|
||||
const formData = config.buildFormData(params, validRegularFiles);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
@ -217,11 +217,11 @@ export const useToolOperation = <TParams>(
|
||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||
if (config.responseHandler) {
|
||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||
processedFiles = await config.responseHandler(response.data, filesWithHistory);
|
||||
processedFiles = await config.responseHandler(response.data, validRegularFiles);
|
||||
} else if (response.data.type === 'application/pdf' ||
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
// Single PDF response (e.g. split with merge option) - use original filename
|
||||
const originalFileName = filesWithHistory[0]?.name || 'document.pdf';
|
||||
const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||
processedFiles = [singleFile];
|
||||
} else {
|
||||
@ -234,10 +234,11 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.custom:
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, filesWithHistory);
|
||||
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -260,21 +261,17 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Replace input files with processed files (consumeFiles handles pinning)
|
||||
const inputFileIds: FileId[] = [];
|
||||
const inputFileRecords: FileRecord[] = [];
|
||||
const inputStirlingFileStubs: StirlingFileStub[] = [];
|
||||
|
||||
// Build parallel arrays of IDs and records for undo tracking
|
||||
for (const file of validFiles) {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
const record = selectors.getFileRecord(fileId);
|
||||
if (record) {
|
||||
inputFileIds.push(fileId);
|
||||
inputFileRecords.push(record);
|
||||
} else {
|
||||
console.warn(`No file record found for file: ${file.name}`);
|
||||
}
|
||||
const fileId = file.fileId;
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record) {
|
||||
inputFileIds.push(fileId);
|
||||
inputStirlingFileStubs.push(record);
|
||||
} else {
|
||||
console.warn(`No file ID found for file: ${file.name}`);
|
||||
console.warn(`No file stub found for file: ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,24 +279,22 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
inputFiles: validFiles, // Keep original File objects for undo
|
||||
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
||||
inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||
outputFileIds
|
||||
};
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
actions.setStatus('');
|
||||
markOperationFailed(fileId, operationId, errorMessage);
|
||||
} finally {
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
cancelApiCalls();
|
||||
@ -328,10 +323,10 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
||||
const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
|
||||
|
||||
// Validate that we have data to undo
|
||||
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
|
||||
if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
|
||||
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||
return;
|
||||
}
|
||||
@ -343,7 +338,8 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
try {
|
||||
// Undo the consume operation
|
||||
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
||||
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
||||
|
||||
|
||||
// Clear results and operation tracking
|
||||
resetResults();
|
||||
|
@ -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;
|
||||
|
@ -70,7 +70,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,8 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { isFileObject } from '../types/fileContext';
|
||||
|
||||
/**
|
||||
* Hook to convert a File object to { file: File; url: string } format
|
||||
@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
|
||||
return useMemo(() => {
|
||||
if (!file) return null;
|
||||
|
||||
// Validate that file is a proper File or Blob object
|
||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
||||
// Validate that file is a proper File, StirlingFile, or Blob object
|
||||
if (!isFileObject(file) && !(file instanceof Blob)) {
|
||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||
return null;
|
||||
}
|
||||
|
@ -2,21 +2,8 @@ import { useState, useEffect } from "react";
|
||||
import { FileMetadata } from "../types/file";
|
||||
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
|
||||
@ -53,7 +40,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
|
||||
// Try to load file from IndexedDB using new context
|
||||
if (file.id && indexedDB) {
|
||||
const loadedFile = await indexedDB.loadFile(file.id);
|
||||
const loadedFile = await indexedDB.loadFile(file.id as FileId);
|
||||
if (!loadedFile) {
|
||||
throw new Error('File not found in IndexedDB');
|
||||
}
|
||||
@ -70,7 +57,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
// Save thumbnail to IndexedDB for persistence
|
||||
if (file.id && indexedDB && thumbnail) {
|
||||
try {
|
||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
||||
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export function usePDFProcessor() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -75,7 +76,7 @@ export function usePDFProcessor() {
|
||||
// Create pages without thumbnails initially - load them lazily
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
originalPageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
|
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