mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29: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
|
insert_final_newline = false
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
[{*.js,*.jsx,*.ts,*.tsx}]
|
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[*.css]
|
[*.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
|
cache-dependency-path: frontend/package-lock.json
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
|
- name: Lint frontend
|
||||||
|
run: cd frontend && npm run lint
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd frontend && npm run build
|
run: cd frontend && npm run build
|
||||||
- name: Run frontend tests
|
- 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
|
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
"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`)
|
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
- **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)
|
- **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
|
## Communication Style
|
||||||
- Be direct and to the point
|
- 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",
|
"predev": "npm run generate-icons",
|
||||||
"dev": "npx tsc --noEmit && vite",
|
"dev": "npx tsc --noEmit && vite",
|
||||||
"prebuild": "npm run generate-icons",
|
"prebuild": "npm run generate-icons",
|
||||||
|
"lint": "npx eslint",
|
||||||
"build": "npx tsc --noEmit && vite build",
|
"build": "npx tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@ -72,6 +73,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.34.0",
|
||||||
"@iconify-json/material-symbols": "^1.2.33",
|
"@iconify-json/material-symbols": "^1.2.33",
|
||||||
"@iconify/utils": "^3.0.1",
|
"@iconify/utils": "^3.0.1",
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
@ -80,6 +82,7 @@
|
|||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@vitejs/plugin-react": "^4.5.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
"@vitest/coverage-v8": "^1.0.0",
|
"@vitest/coverage-v8": "^1.0.0",
|
||||||
|
"eslint": "^9.34.0",
|
||||||
"jsdom": "^23.0.0",
|
"jsdom": "^23.0.0",
|
||||||
"license-checker": "^25.0.1",
|
"license-checker": "^25.0.1",
|
||||||
"madge": "^8.0.0",
|
"madge": "^8.0.0",
|
||||||
@ -87,7 +90,8 @@
|
|||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.2",
|
||||||
|
"typescript-eslint": "^8.42.0",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
}
|
}
|
||||||
|
@ -1305,7 +1305,48 @@
|
|||||||
"title": "Flatten",
|
"title": "Flatten",
|
||||||
"header": "Flatten PDF",
|
"header": "Flatten PDF",
|
||||||
"flattenOnlyForms": "Flatten only forms",
|
"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": {
|
"repair": {
|
||||||
"tags": "fix,restore,correction,recover",
|
"tags": "fix,restore,correction,recover",
|
||||||
|
@ -107,7 +107,7 @@ async function main() {
|
|||||||
needsRegeneration = false;
|
needsRegeneration = false;
|
||||||
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
|
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
|
// If we can't parse existing file, regenerate
|
||||||
needsRegeneration = true;
|
needsRegeneration = true;
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ try {
|
|||||||
// Install license-checker if not present
|
// Install license-checker if not present
|
||||||
try {
|
try {
|
||||||
require.resolve('license-checker');
|
require.resolve('license-checker');
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.log('📦 Installing license-checker...');
|
console.log('📦 Installing license-checker...');
|
||||||
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
|
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)"
|
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||||
// Extract the first license from compound expressions for URL
|
// 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]]) {
|
if (match && licenseUrls[match[1]]) {
|
||||||
return licenseUrls[match[1]];
|
return licenseUrls[match[1]];
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
|
|
||||||
|
// Import file ID debugging helpers (development only)
|
||||||
|
import "./utils/fileIdSafety";
|
||||||
|
|
||||||
// Loading component for i18next suspense
|
// Loading component for i18next suspense
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div
|
<div
|
||||||
|
@ -4,7 +4,6 @@ import { Dropzone } from '@mantine/dropzone';
|
|||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { useFileManager } from '../hooks/useFileManager';
|
import { useFileManager } from '../hooks/useFileManager';
|
||||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||||
import { createFileId } from '../types/fileContext';
|
|
||||||
import { Tool } from '../types/tool';
|
import { Tool } from '../types/tool';
|
||||||
import MobileLayout from './fileManager/MobileLayout';
|
import MobileLayout from './fileManager/MobileLayout';
|
||||||
import DesktopLayout from './fileManager/DesktopLayout';
|
import DesktopLayout from './fileManager/DesktopLayout';
|
||||||
@ -21,13 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
|
||||||
|
|
||||||
// Wrapper for storeFile that generates UUID
|
|
||||||
const storeFileWithId = useCallback(async (file: File) => {
|
|
||||||
const fileId = createFileId(); // Generate UUID for storage
|
|
||||||
return await storeFile(file, fileId);
|
|
||||||
}, [storeFile]);
|
|
||||||
|
|
||||||
// File management handlers
|
// File management handlers
|
||||||
const isFileSupported = useCallback((fileName: string) => {
|
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 {
|
import {
|
||||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
|
||||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
|
||||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
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 { zipFileService } from '../../services/zipFileService';
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
import styles from './FileEditor.module.css';
|
|
||||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||||
import FilePickerModal from '../shared/FilePickerModal';
|
import FilePickerModal from '../shared/FilePickerModal';
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||||
|
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
onOpenPageEditor?: (file: File) => void;
|
onOpenPageEditor?: () => void;
|
||||||
onMergeFiles?: (files: File[]) => void;
|
onMergeFiles?: (files: StirlingFile[]) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
showUpload?: boolean;
|
|
||||||
showBulkActions?: boolean;
|
|
||||||
supportedExtensions?: string[];
|
supportedExtensions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileEditor = ({
|
const FileEditor = ({
|
||||||
onOpenPageEditor,
|
|
||||||
onMergeFiles,
|
|
||||||
toolMode = false,
|
toolMode = false,
|
||||||
showUpload = true,
|
|
||||||
showBulkActions = true,
|
|
||||||
supportedExtensions = ["pdf"]
|
supportedExtensions = ["pdf"]
|
||||||
}: FileEditorProps) => {
|
}: FileEditorProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Utility function to check if a file extension is supported
|
// Utility function to check if a file extension is supported
|
||||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||||
@ -49,13 +35,10 @@ const FileEditor = ({
|
|||||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||||
|
|
||||||
// Extract needed values from state (memoized to prevent infinite loops)
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||||
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
|
||||||
const selectedFileIds = state.ui.selectedFileIds;
|
const selectedFileIds = state.ui.selectedFileIds;
|
||||||
const isProcessing = state.ui.isProcessing;
|
|
||||||
|
|
||||||
// Get the real context actions
|
// Get navigation actions
|
||||||
const { actions } = useFileActions();
|
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
|
||||||
// Get file selection context
|
// Get file selection context
|
||||||
@ -92,10 +75,10 @@ const FileEditor = ({
|
|||||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||||
contextSelectedIdsRef.current = contextSelectedIds;
|
contextSelectedIdsRef.current = contextSelectedIds;
|
||||||
|
|
||||||
// Use activeFileRecords directly - no conversion needed
|
// Use activeStirlingFileStubs directly - no conversion needed
|
||||||
const localSelectedIds = contextSelectedIds;
|
const localSelectedIds = contextSelectedIds;
|
||||||
|
|
||||||
// Helper to convert FileRecord to FileThumbnail format
|
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||||
const recordToFileItem = useCallback((record: any) => {
|
const recordToFileItem = useCallback((record: any) => {
|
||||||
const file = selectors.getFile(record.id);
|
const file = selectors.getFile(record.id);
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
@ -162,29 +145,9 @@ const FileEditor = ({
|
|||||||
if (extractionResult.success) {
|
if (extractionResult.success) {
|
||||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||||
|
|
||||||
// Record ZIP extraction operation
|
if (extractionResult.errors.length > 0) {
|
||||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
errors.push(...extractionResult.errors);
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: 'convert',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */,
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: file.name,
|
|
||||||
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
|
|
||||||
fileSize: file.size,
|
|
||||||
parameters: {
|
|
||||||
extractionType: 'zip',
|
|
||||||
extractedCount: extractionResult.extractedCount,
|
|
||||||
totalFiles: extractionResult.totalFiles
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (extractionResult.errors.length > 0) {
|
|
||||||
errors.push(...extractionResult.errors);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
@ -214,25 +177,6 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Process all extracted files
|
// Process all extracted files
|
||||||
if (allExtractedFiles.length > 0) {
|
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)
|
// Add files to context (they will be processed automatically)
|
||||||
await addFiles(allExtractedFiles);
|
await addFiles(allExtractedFiles);
|
||||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||||
@ -253,27 +197,10 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
}, [addFiles]);
|
}, [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 toggleFile = useCallback((fileId: FileId) => {
|
||||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||||
|
|
||||||
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
if (!targetRecord) return;
|
if (!targetRecord) return;
|
||||||
|
|
||||||
const contextFileId = fileId; // No need to create a new ID
|
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)
|
// Update context (this automatically updates tool selection since they use the same action)
|
||||||
setSelectedFiles(newSelection);
|
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
|
// File reordering handler for drag and drop
|
||||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
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
|
// Find indices
|
||||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||||
@ -369,71 +287,34 @@ const FileEditor = ({
|
|||||||
// Update status
|
// Update status
|
||||||
const moveCount = filesToMove.length;
|
const moveCount = filesToMove.length;
|
||||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||||
}, [activeFileRecords, reorderFiles, setStatus]);
|
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// File operations using context
|
// File operations using context
|
||||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
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;
|
const file = record ? selectors.getFile(record.id) : null;
|
||||||
|
|
||||||
if (record && file) {
|
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)
|
// Remove file from context but keep in storage (close, don't delete)
|
||||||
|
const contextFileId = record.id;
|
||||||
removeFiles([contextFileId], false);
|
removeFiles([contextFileId], false);
|
||||||
|
|
||||||
// Remove from context selections
|
// Remove from context selections
|
||||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||||
setSelectedFiles(currentSelected);
|
setSelectedFiles(currentSelected);
|
||||||
}
|
}
|
||||||
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||||
|
|
||||||
const handleViewFile = useCallback((fileId: FileId) => {
|
const handleViewFile = useCallback((fileId: FileId) => {
|
||||||
const record = activeFileRecords.find(r => r.id === fileId);
|
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
if (record) {
|
if (record) {
|
||||||
// Set the file as selected in context and switch to viewer for preview
|
// Set the file as selected in context and switch to viewer for preview
|
||||||
setSelectedFiles([fileId]);
|
setSelectedFiles([fileId]);
|
||||||
navActions.setWorkbench('viewer');
|
navActions.setWorkbench('viewer');
|
||||||
}
|
}
|
||||||
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
|
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||||
|
|
||||||
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
|
||||||
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
|
||||||
if (startIndex === -1) return;
|
|
||||||
|
|
||||||
const recordsToMerge = activeFileRecords.slice(startIndex);
|
|
||||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
|
|
||||||
if (onMergeFiles) {
|
|
||||||
onMergeFiles(filesToMerge);
|
|
||||||
}
|
|
||||||
}, [activeFileRecords, selectors, onMergeFiles]);
|
|
||||||
|
|
||||||
const handleSplitFile = useCallback((fileId: FileId) => {
|
|
||||||
const file = selectors.getFile(fileId);
|
|
||||||
if (file && onOpenPageEditor) {
|
|
||||||
onOpenPageEditor(file);
|
|
||||||
}
|
|
||||||
}, [selectors, onOpenPageEditor]);
|
|
||||||
|
|
||||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
@ -468,7 +349,7 @@ const FileEditor = ({
|
|||||||
<Box p="md" pt="xl">
|
<Box p="md" pt="xl">
|
||||||
|
|
||||||
|
|
||||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||||
<Center h="60vh">
|
<Center h="60vh">
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<Text size="lg" c="dimmed">📁</Text>
|
<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>
|
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||||
<Box>
|
<Box>
|
||||||
<SkeletonLoader type="controls" />
|
<SkeletonLoader type="controls" />
|
||||||
|
|
||||||
@ -523,7 +404,7 @@ const FileEditor = ({
|
|||||||
pointerEvents: 'auto'
|
pointerEvents: 'auto'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeFileRecords.map((record, index) => {
|
{activeStirlingFileStubs.map((record, index) => {
|
||||||
const fileItem = recordToFileItem(record);
|
const fileItem = recordToFileItem(record);
|
||||||
if (!fileItem) return null;
|
if (!fileItem) return null;
|
||||||
|
|
||||||
@ -532,7 +413,7 @@ const FileEditor = ({
|
|||||||
key={record.id}
|
key={record.id}
|
||||||
file={fileItem}
|
file={fileItem}
|
||||||
index={index}
|
index={index}
|
||||||
totalFiles={activeFileRecords.length}
|
totalFiles={activeStirlingFileStubs.length}
|
||||||
selectedFiles={localSelectedIds}
|
selectedFiles={localSelectedIds}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
onToggleFile={toggleFile}
|
onToggleFile={toggleFile}
|
||||||
|
@ -45,7 +45,6 @@ const FileEditorThumbnail = ({
|
|||||||
selectedFiles,
|
selectedFiles,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onViewFile,
|
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onReorderFiles,
|
onReorderFiles,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
@ -62,8 +61,8 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
return activeFiles.find(f => f.fileId === file.id);
|
||||||
}, [activeFiles, file.name, file.size]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
// Get file record to access tool history
|
// 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 { Stack, Button, Box } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||||
@ -11,27 +11,26 @@ interface FileDetailsProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileDetails: React.FC<FileDetailsProps> = ({
|
const FileDetails: React.FC<FileDetailsProps> = ({
|
||||||
compact = false
|
compact = false
|
||||||
}) => {
|
}) => {
|
||||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
// Get the currently displayed file
|
// Get the currently displayed file
|
||||||
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||||
const hasSelection = selectedFiles.length > 0;
|
const hasSelection = selectedFiles.length > 0;
|
||||||
const hasMultipleFiles = selectedFiles.length > 1;
|
|
||||||
|
|
||||||
// Use IndexedDB hook for the current file
|
// Use IndexedDB hook for the current file
|
||||||
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||||
|
|
||||||
// Get thumbnail for current file
|
// Get thumbnail for current file
|
||||||
const getCurrentThumbnail = () => {
|
const getCurrentThumbnail = () => {
|
||||||
return currentThumbnail;
|
return currentThumbnail;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevious = () => {
|
const handlePrevious = () => {
|
||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
@ -40,7 +39,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
@ -49,14 +48,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset index when selection changes
|
// Reset index when selection changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentFileIndex >= selectedFiles.length) {
|
if (currentFileIndex >= selectedFiles.length) {
|
||||||
setCurrentFileIndex(0);
|
setCurrentFileIndex(0);
|
||||||
}
|
}
|
||||||
}, [selectedFiles.length, currentFileIndex]);
|
}, [selectedFiles.length, currentFileIndex]);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<CompactFileDetails
|
<CompactFileDetails
|
||||||
@ -88,26 +87,26 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 2: File Details */}
|
{/* Section 2: File Details */}
|
||||||
<FileInfoCard
|
<FileInfoCard
|
||||||
currentFile={currentFile}
|
currentFile={currentFile}
|
||||||
modalHeight={modalHeight}
|
modalHeight={modalHeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
mb="xl"
|
mb="xl"
|
||||||
onClick={onOpenFiles}
|
onClick={onOpenFiles}
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||||
color: 'white'
|
color: 'white'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedFiles.length > 1
|
{selectedFiles.length > 1
|
||||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
: t('fileManager.openFile', 'Open File')
|
: t('fileManager.openFile', 'Open File')
|
||||||
}
|
}
|
||||||
@ -116,4 +115,4 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FileDetails;
|
export default FileDetails;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Stack, Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import FileSourceButtons from './FileSourceButtons';
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
import FileDetails from './FileDetails';
|
import FileDetails from './FileDetails';
|
||||||
import SearchInput from './SearchInput';
|
import SearchInput from './SearchInput';
|
||||||
@ -19,14 +19,14 @@ const MobileLayout: React.FC = () => {
|
|||||||
const calculateFileListHeight = () => {
|
const calculateFileListHeight = () => {
|
||||||
// Base modal height minus padding and gaps
|
// Base modal height minus padding and gaps
|
||||||
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
||||||
|
|
||||||
// Estimate heights of fixed components
|
// Estimate heights of fixed components
|
||||||
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
||||||
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
||||||
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
|
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
|
||||||
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
||||||
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
|
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
|
||||||
|
|
||||||
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
|
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -36,15 +36,15 @@ const MobileLayout: React.FC = () => {
|
|||||||
<Box style={{ flexShrink: 0 }}>
|
<Box style={{ flexShrink: 0 }}>
|
||||||
<FileSourceButtons horizontal={true} />
|
<FileSourceButtons horizontal={true} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box style={{ flexShrink: 0 }}>
|
<Box style={{ flexShrink: 0 }}>
|
||||||
<FileDetails compact={true} />
|
<FileDetails compact={true} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
||||||
<Box style={{
|
<Box style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: 'var(--bg-file-list)',
|
backgroundColor: 'var(--bg-file-list)',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
@ -54,13 +54,13 @@ const MobileLayout: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
{activeSource === 'recent' && (
|
{activeSource === 'recent' && (
|
||||||
<>
|
<>
|
||||||
<Box style={{
|
<Box style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||||
}}>
|
}}>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{
|
<Box style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||||
}}>
|
}}>
|
||||||
@ -68,11 +68,11 @@ const MobileLayout: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||||
<FileListArea
|
<FileListArea
|
||||||
scrollAreaHeight={calculateFileListHeight()}
|
scrollAreaHeight={calculateFileListHeight()}
|
||||||
scrollAreaStyle={{
|
scrollAreaStyle={{
|
||||||
height: calculateFileListHeight(),
|
height: calculateFileListHeight(),
|
||||||
maxHeight: '60vh',
|
maxHeight: '60vh',
|
||||||
minHeight: '9.375rem',
|
minHeight: '9.375rem',
|
||||||
@ -83,11 +83,11 @@ const MobileLayout: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Hidden file input for local file selection */}
|
{/* Hidden file input for local file selection */}
|
||||||
<HiddenFileInput />
|
<HiddenFileInput />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MobileLayout;
|
export default MobileLayout;
|
||||||
|
@ -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 { Box } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||||
@ -19,7 +17,6 @@ import Footer from '../shared/Footer';
|
|||||||
|
|
||||||
// No props needed - component uses contexts directly
|
// No props needed - component uses contexts directly
|
||||||
export default function Workbench() {
|
export default function Workbench() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
|
||||||
// Use context-based hooks to eliminate all prop drilling
|
// Use context-based hooks to eliminate all prop drilling
|
||||||
@ -78,11 +75,9 @@ export default function Workbench() {
|
|||||||
return (
|
return (
|
||||||
<FileEditor
|
<FileEditor
|
||||||
toolMode={!!selectedToolId}
|
toolMode={!!selectedToolId}
|
||||||
showUpload={true}
|
|
||||||
showBulkActions={!selectedToolId}
|
|
||||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||||
{...(!selectedToolId && {
|
{...(!selectedToolId && {
|
||||||
onOpenPageEditor: (file) => {
|
onOpenPageEditor: () => {
|
||||||
setCurrentView("pageEditor");
|
setCurrentView("pageEditor");
|
||||||
},
|
},
|
||||||
onMergeFiles: (filesToMerge) => {
|
onMergeFiles: (filesToMerge) => {
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
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';
|
import { GRID_CONSTANTS } from './constants';
|
||||||
|
|
||||||
interface DragDropItem {
|
interface DragDropItem {
|
||||||
@ -22,65 +20,60 @@ interface DragDropGridProps<T extends DragDropItem> {
|
|||||||
|
|
||||||
const DragDropGrid = <T extends DragDropItem>({
|
const DragDropGrid = <T extends DragDropItem>({
|
||||||
items,
|
items,
|
||||||
selectedItems,
|
|
||||||
selectionMode,
|
|
||||||
isAnimating,
|
|
||||||
onReorderPages,
|
|
||||||
renderItem,
|
renderItem,
|
||||||
renderSplitMarker,
|
|
||||||
}: DragDropGridProps<T>) => {
|
}: DragDropGridProps<T>) => {
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Responsive grid configuration
|
// Responsive grid configuration
|
||||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||||
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||||
|
|
||||||
// Calculate items per row based on container width
|
// Calculate items per row based on container width
|
||||||
const calculateItemsPerRow = useCallback(() => {
|
const calculateItemsPerRow = useCallback(() => {
|
||||||
if (!containerRef.current) return 4; // Default fallback
|
if (!containerRef.current) return 4; // Default fallback
|
||||||
|
|
||||||
const containerWidth = containerRef.current.offsetWidth;
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
if (containerWidth === 0) return 4; // Container not measured yet
|
if (containerWidth === 0) return 4; // Container not measured yet
|
||||||
|
|
||||||
// Convert rem to pixels for calculation
|
// Convert rem to pixels for calculation
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||||
|
|
||||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||||
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||||
const calculated = Math.floor(availableWidth / itemWithGap);
|
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||||
|
|
||||||
return Math.max(1, calculated); // At least 1 item per row
|
return Math.max(1, calculated); // At least 1 item per row
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update items per row when container resizes
|
// Update items per row when container resizes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateLayout = () => {
|
const updateLayout = () => {
|
||||||
const newItemsPerRow = calculateItemsPerRow();
|
const newItemsPerRow = calculateItemsPerRow();
|
||||||
setItemsPerRow(newItemsPerRow);
|
setItemsPerRow(newItemsPerRow);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial calculation
|
// Initial calculation
|
||||||
updateLayout();
|
updateLayout();
|
||||||
|
|
||||||
// Listen for window resize
|
// Listen for window resize
|
||||||
window.addEventListener('resize', updateLayout);
|
window.addEventListener('resize', updateLayout);
|
||||||
|
|
||||||
// Use ResizeObserver for container size changes
|
// Use ResizeObserver for container size changes
|
||||||
const resizeObserver = new ResizeObserver(updateLayout);
|
const resizeObserver = new ResizeObserver(updateLayout);
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', updateLayout);
|
window.removeEventListener('resize', updateLayout);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [calculateItemsPerRow]);
|
}, [calculateItemsPerRow]);
|
||||||
|
|
||||||
// Virtualization with react-virtual library
|
// Virtualization with react-virtual library
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: Math.ceil(items.length / itemsPerRow),
|
count: Math.ceil(items.length / itemsPerRow),
|
||||||
@ -92,8 +85,6 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
overscan: OVERSCAN,
|
overscan: OVERSCAN,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Calculate optimal width for centering
|
// Calculate optimal width for centering
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||||
@ -101,9 +92,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
// Basic container styles
|
// Basic container styles
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@ -122,7 +113,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const startIndex = virtualRow.index * itemsPerRow;
|
const startIndex = virtualRow.index * itemsPerRow;
|
||||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||||
const rowItems = items.slice(startIndex, endIndex);
|
const rowItems = items.slice(startIndex, endIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualRow.index}
|
key={virtualRow.index}
|
||||||
@ -154,7 +145,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||||
@ -44,7 +44,6 @@ const FileThumbnail = ({
|
|||||||
selectedFiles,
|
selectedFiles,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onViewFile,
|
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onReorderFiles,
|
onReorderFiles,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
@ -61,8 +60,8 @@ const FileThumbnail = ({
|
|||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
return activeFiles.find(f => f.fileId === file.id);
|
||||||
}, [activeFiles, file.name, file.size]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
const downloadSelectedFile = useCallback(() => {
|
const downloadSelectedFile = useCallback(() => {
|
||||||
@ -93,40 +92,6 @@ const FileThumbnail = ({
|
|||||||
// ---- Selection ----
|
// ---- Selection ----
|
||||||
const isSelected = selectedFiles.includes(file.id);
|
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 ----
|
// ---- Drag & drop wiring ----
|
||||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import {
|
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
Button, Text, Center, Box,
|
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||||
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||||
Stack, Group, Portal
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
|
|
||||||
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
|
|
||||||
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
|
||||||
import { pdfExportService } from "../../services/pdfExportService";
|
import { pdfExportService } from "../../services/pdfExportService";
|
||||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||||
@ -19,16 +13,11 @@ import NavigationWarningModal from '../shared/NavigationWarningModal';
|
|||||||
import { FileId } from "../../types/file";
|
import { FileId } from "../../types/file";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DOMCommand,
|
|
||||||
RotatePageCommand,
|
|
||||||
DeletePagesCommand,
|
DeletePagesCommand,
|
||||||
ReorderPagesCommand,
|
ReorderPagesCommand,
|
||||||
SplitCommand,
|
SplitCommand,
|
||||||
BulkRotateCommand,
|
BulkRotateCommand,
|
||||||
BulkSplitCommand,
|
|
||||||
SplitAllCommand,
|
|
||||||
PageBreakCommand,
|
PageBreakCommand,
|
||||||
BulkPageBreakCommand,
|
|
||||||
UndoManager
|
UndoManager
|
||||||
} from './commands/pageCommands';
|
} from './commands/pageCommands';
|
||||||
import { GRID_CONSTANTS } from './constants';
|
import { GRID_CONSTANTS } from './constants';
|
||||||
@ -49,35 +38,24 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// Prefer IDs + selectors to avoid array identity churn
|
// Prefer IDs + selectors to avoid array identity churn
|
||||||
const activeFileIds = state.files.ids;
|
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
|
// UI state
|
||||||
const globalProcessing = state.ui.isProcessing;
|
const globalProcessing = state.ui.isProcessing;
|
||||||
const processingProgress = state.ui.processingProgress;
|
|
||||||
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
|
|
||||||
|
|
||||||
// Edit state management
|
// Edit state management
|
||||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
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)
|
// DOM-first undo manager (replaces the old React state undo system)
|
||||||
const undoManagerRef = useRef(new UndoManager());
|
const undoManagerRef = useRef(new UndoManager());
|
||||||
|
|
||||||
// Document state management
|
// Document state management
|
||||||
const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument();
|
const { document: mergedPdfDocument } = usePageDocument();
|
||||||
|
|
||||||
|
|
||||||
// UI state management
|
// UI state management
|
||||||
const {
|
const {
|
||||||
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
|
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
|
||||||
setSelectionMode, setSelectedPageIds, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading,
|
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
|
||||||
togglePage, toggleSelectAll, animateReorder
|
togglePage, toggleSelectAll, animateReorder
|
||||||
} = usePageEditorState();
|
} = usePageEditorState();
|
||||||
|
|
||||||
@ -146,12 +124,6 @@ const PageEditor = ({
|
|||||||
}).filter(id => id !== '');
|
}).filter(id => id !== '');
|
||||||
}, [displayDocument]);
|
}, [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
|
// Select all pages by default when document initially loads
|
||||||
const hasInitializedSelection = useRef(false);
|
const hasInitializedSelection = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@ -9,9 +8,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
|
|||||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
|
|
||||||
interface PageEditorControlsProps {
|
interface PageEditorControlsProps {
|
||||||
// Close/Reset functions
|
// Close/Reset functions
|
||||||
@ -46,7 +43,6 @@ interface PageEditorControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PageEditorControls = ({
|
const PageEditorControls = ({
|
||||||
onClosePdf,
|
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
canUndo,
|
canUndo,
|
||||||
@ -54,12 +50,7 @@ const PageEditorControls = ({
|
|||||||
onRotate,
|
onRotate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSplit,
|
onSplit,
|
||||||
onSplitAll,
|
|
||||||
onPageBreak,
|
onPageBreak,
|
||||||
onPageBreakAll,
|
|
||||||
onExportAll,
|
|
||||||
exportLoading,
|
|
||||||
selectionMode,
|
|
||||||
selectedPageIds,
|
selectedPageIds,
|
||||||
displayDocument,
|
displayDocument,
|
||||||
splitPositions,
|
splitPositions,
|
||||||
|
@ -52,16 +52,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
pageRefs,
|
pageRefs,
|
||||||
onReorderPages,
|
onReorderPages,
|
||||||
onTogglePage,
|
onTogglePage,
|
||||||
onAnimateReorder,
|
|
||||||
onExecuteCommand,
|
onExecuteCommand,
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onSetMovingPage,
|
onSetMovingPage,
|
||||||
onDeletePage,
|
onDeletePage,
|
||||||
createRotateCommand,
|
createRotateCommand,
|
||||||
createDeleteCommand,
|
|
||||||
createSplitCommand,
|
createSplitCommand,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
setPdfDocument,
|
|
||||||
splitPositions,
|
splitPositions,
|
||||||
onInsertFiles,
|
onInsertFiles,
|
||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
@ -172,7 +169,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
type: 'page',
|
type: 'page',
|
||||||
pageNumber: page.pageNumber
|
pageNumber: page.pageNumber
|
||||||
}),
|
}),
|
||||||
onDrop: ({ source }) => {}
|
onDrop: (_) => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
(element as any).__dragCleanup = () => {
|
(element as any).__dragCleanup = () => {
|
||||||
|
@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const globalProcessing = state.ui.isProcessing;
|
const globalProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
// Get primary file record outside useMemo to track processedFile changes
|
// Get primary file record outside useMemo to track processedFile changes
|
||||||
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
|
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||||
const processedFilePages = primaryFileRecord?.processedFile?.pages;
|
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||||
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
|
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||||
|
|
||||||
// Compute merged document with stable signature (prevents infinite loops)
|
// Compute merged document with stable signature (prevents infinite loops)
|
||||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||||
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
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 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');
|
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
activeFileIds.length === 1
|
activeFileIds.length === 1
|
||||||
? (primaryFileRecord.name ?? 'document.pdf')
|
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||||
: activeFileIds
|
: activeFileIds
|
||||||
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||||
.join(' + ');
|
.join(' + ');
|
||||||
|
|
||||||
// Build page insertion map from files with insertion positions
|
// Build page insertion map from files with insertion positions
|
||||||
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const originalFileIds: FileId[] = [];
|
const originalFileIds: FileId[] = [];
|
||||||
|
|
||||||
activeFileIds.forEach(fileId => {
|
activeFileIds.forEach(fileId => {
|
||||||
const record = selectors.getFileRecord(fileId);
|
const record = selectors.getStirlingFileStub(fileId);
|
||||||
if (record?.insertAfterPageId !== undefined) {
|
if (record?.insertAfterPageId !== undefined) {
|
||||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||||
insertionMap.set(record.insertAfterPageId, []);
|
insertionMap.set(record.insertAfterPageId, []);
|
||||||
@ -68,16 +68,15 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
|
|
||||||
// Build pages by interleaving original pages with insertions
|
// Build pages by interleaving original pages with insertions
|
||||||
let pages: PDFPage[] = [];
|
let pages: PDFPage[] = [];
|
||||||
let totalPageCount = 0;
|
|
||||||
|
|
||||||
// Helper function to create pages from a file
|
// Helper function to create pages from a file
|
||||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||||
const fileRecord = selectors.getFileRecord(fileId);
|
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||||
if (!fileRecord) {
|
if (!stirlingFileStub) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedFile = fileRecord.processedFile;
|
const processedFile = stirlingFileStub.processedFile;
|
||||||
let filePages: PDFPage[] = [];
|
let filePages: PDFPage[] = [];
|
||||||
|
|
||||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||||
@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPageCount = pages.length;
|
|
||||||
|
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -159,7 +156,7 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return mergedDoc;
|
return mergedDoc;
|
||||||
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||||
|
|
||||||
// Large document detection for smart loading
|
// Large document detection for smart loading
|
||||||
const isVeryLargeDocument = useMemo(() => {
|
const isVeryLargeDocument = useMemo(() => {
|
||||||
|
@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Tooltip } from './Tooltip';
|
import { Tooltip } from './Tooltip';
|
||||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
|
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
|
||||||
|
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
|
||||||
|
|
||||||
interface AllToolsNavButtonProps {
|
interface AllToolsNavButtonProps {
|
||||||
activeButton: string;
|
activeButton: string;
|
||||||
@ -13,6 +15,7 @@ interface AllToolsNavButtonProps {
|
|||||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||||
|
const { getHomeNavigation } = useSidebarNavigation();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setActiveButton('tools');
|
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)
|
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
||||||
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
|
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
|
||||||
|
|
||||||
|
const navProps = getHomeNavigation();
|
||||||
|
|
||||||
|
const handleNavClick = (e: React.MouseEvent) => {
|
||||||
|
handleUnlessSpecialClick(e, handleClick);
|
||||||
|
};
|
||||||
|
|
||||||
const iconNode = (
|
const iconNode = (
|
||||||
<span className="iconContainer">
|
<span className="iconContainer">
|
||||||
<AppsIcon sx={{ fontSize: '2rem' }} />
|
<AppsIcon sx={{ fontSize: '2rem' }} />
|
||||||
@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
<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">
|
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={navProps.href}
|
||||||
|
onClick={handleNavClick}
|
||||||
size={'lg'}
|
size={'lg'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={handleClick}
|
aria-label={t("quickAccess.allTools", "All Tools")}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
|
textDecoration: 'none'
|
||||||
}}
|
}}
|
||||||
className={isActive ? 'activeIconScale' : ''}
|
className={isActive ? 'activeIconScale' : ''}
|
||||||
>
|
>
|
||||||
|
@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
|
|||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
import { FileRecord } from "../../types/fileContext";
|
import { StirlingFileStub } from "../../types/fileContext";
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: File;
|
file: File;
|
||||||
record?: FileRecord;
|
record?: StirlingFileStub;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onView?: () => void;
|
onView?: () => void;
|
||||||
@ -25,7 +25,7 @@ interface FileCardProps {
|
|||||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
// 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 { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import React, { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
|
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import SortIcon from "@mui/icons-material/Sort";
|
import SortIcon from "@mui/icons-material/Sort";
|
||||||
import FileCard from "./FileCard";
|
import FileCard from "./FileCard";
|
||||||
import { FileRecord } from "../../types/fileContext";
|
import { StirlingFileStub } from "../../types/fileContext";
|
||||||
import { FileId } from "../../types/file";
|
import { FileId } from "../../types/file";
|
||||||
|
|
||||||
interface FileGridProps {
|
interface FileGridProps {
|
||||||
files: Array<{ file: File; record?: FileRecord }>;
|
files: Array<{ file: File; record?: StirlingFileStub }>;
|
||||||
onRemove?: (index: number) => void;
|
onRemove?: (index: number) => void;
|
||||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||||
onSelect?: (fileId: FileId) => void;
|
onSelect?: (fileId: FileId) => void;
|
||||||
selectedFiles?: FileId[];
|
selectedFiles?: FileId[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
@ -123,9 +123,17 @@ const FileGrid = ({
|
|||||||
h="30rem"
|
h="30rem"
|
||||||
style={{ overflowY: "auto", width: "100%" }}
|
style={{ overflowY: "auto", width: "100%" }}
|
||||||
>
|
>
|
||||||
{displayFiles.map((item, idx) => {
|
{displayFiles
|
||||||
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
|
.filter(item => {
|
||||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
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;
|
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||||
return (
|
return (
|
||||||
<FileCard
|
<FileCard
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { Dropzone } from '@mantine/dropzone';
|
||||||
import LocalIcon from './LocalIcon';
|
import LocalIcon from './LocalIcon';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
@ -15,7 +15,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||||
const [isChanging, setIsChanging] = useState(false);
|
|
||||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||||
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | 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
|
// Start transition animation
|
||||||
setIsChanging(true);
|
|
||||||
setPendingLanguage(value);
|
setPendingLanguage(value);
|
||||||
|
|
||||||
// Simulate processing time for smooth transition
|
// Simulate processing time for smooth transition
|
||||||
@ -44,7 +42,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
i18n.changeLanguage(value);
|
i18n.changeLanguage(value);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsChanging(false);
|
|
||||||
setPendingLanguage(null);
|
setPendingLanguage(null);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
|
|
||||||
@ -54,7 +51,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||||
supportedLanguages['en-GB'];
|
supportedLanguages['en-GB'];
|
||||||
|
|
||||||
// Trigger animation when dropdown opens
|
// Trigger animation when dropdown opens
|
||||||
@ -77,8 +74,8 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
<Menu
|
<Menu
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={setOpened}
|
onChange={setOpened}
|
||||||
width={600}
|
width={600}
|
||||||
position={position}
|
position={position}
|
||||||
@ -166,15 +163,15 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: option.value === i18n.language
|
backgroundColor: option.value === i18n.language
|
||||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||||
: 'transparent',
|
: 'transparent',
|
||||||
color: option.value === i18n.language
|
color: option.value === i18n.language
|
||||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||||
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: option.value === i18n.language
|
backgroundColor: option.value === i18n.language
|
||||||
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
||||||
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-1px)',
|
||||||
@ -223,4 +220,4 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageSelector;
|
export default LanguageSelector;
|
||||||
|
@ -13,7 +13,7 @@ try {
|
|||||||
localIconCount = Object.keys(iconSet.icons || {}).length;
|
localIconCount = Object.keys(iconSet.icons || {}).length;
|
||||||
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
|
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');
|
console.info('ℹ️ Local icons not available - using CDN fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,10 +31,10 @@ interface LocalIconProps {
|
|||||||
*/
|
*/
|
||||||
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
|
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
|
||||||
// Convert our icon naming convention to the local collection format
|
// Convert our icon naming convention to the local collection format
|
||||||
const iconName = icon.startsWith('material-symbols:')
|
const iconName = icon.startsWith('material-symbols:')
|
||||||
? icon
|
? icon
|
||||||
: `material-symbols:${icon}`;
|
: `material-symbols:${icon}`;
|
||||||
|
|
||||||
// Development logging (only in dev mode)
|
// Development logging (only in dev mode)
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
const logKey = `icon-${iconName}`;
|
const logKey = `icon-${iconName}`;
|
||||||
@ -44,9 +44,9 @@ export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
|
|||||||
sessionStorage.setItem(logKey, 'logged');
|
sessionStorage.setItem(logKey, 'logged');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always render the icon - Iconify will use local if available, CDN if not
|
// Always render the icon - Iconify will use local if available, CDN if not
|
||||||
return <Icon icon={iconName} {...props} />;
|
return <Icon icon={iconName} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LocalIcon;
|
export default LocalIcon;
|
||||||
|
@ -3,10 +3,11 @@ import { ActionIcon, Stack, Divider } from "@mantine/core";
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LocalIcon from './LocalIcon';
|
import LocalIcon from './LocalIcon';
|
||||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||||
import AppConfigModal from './AppConfigModal';
|
|
||||||
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
|
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
|
||||||
|
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
|
||||||
import { ButtonConfig } from '../../types/sidebar';
|
import { ButtonConfig } from '../../types/sidebar';
|
||||||
import './quickAccessBar/QuickAccessBar.css';
|
import './quickAccessBar/QuickAccessBar.css';
|
||||||
import AllToolsNavButton from './AllToolsNavButton';
|
import AllToolsNavButton from './AllToolsNavButton';
|
||||||
@ -17,12 +18,12 @@ import {
|
|||||||
getActiveNavButton,
|
getActiveNavButton,
|
||||||
} from './quickAccessBar/QuickAccessBar';
|
} from './quickAccessBar/QuickAccessBar';
|
||||||
|
|
||||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||||
}, ref) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||||
|
const { getToolNavigation } = useSidebarNavigation();
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
@ -37,6 +38,52 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
openFilesModal();
|
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[] = [
|
const buttonConfigs: ButtonConfig[] = [
|
||||||
{
|
{
|
||||||
@ -153,27 +200,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
<Stack gap="lg" align="center">
|
<Stack gap="lg" align="center">
|
||||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||||
<React.Fragment key={config.id}>
|
<React.Fragment key={config.id}>
|
||||||
|
{renderNavButton(config, index)}
|
||||||
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
|
||||||
<ActionIcon
|
|
||||||
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'lg') : 'lg'}
|
|
||||||
variant="subtle"
|
|
||||||
onClick={() => {
|
|
||||||
config.onClick();
|
|
||||||
}}
|
|
||||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
|
||||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
|
||||||
data-testid={`${config.id}-button`}
|
|
||||||
>
|
|
||||||
<span className="iconContainer">
|
|
||||||
{config.icon}
|
|
||||||
</span>
|
|
||||||
</ActionIcon>
|
|
||||||
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
|
|
||||||
{config.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
||||||
{index === 1 && (
|
{index === 1 && (
|
||||||
|
@ -29,12 +29,11 @@ export default function RightRail() {
|
|||||||
|
|
||||||
// File state and selection
|
// File state and selection
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
|
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
const filesSignature = selectors.getFilesSignature();
|
const filesSignature = selectors.getFilesSignature();
|
||||||
const fileRecords = selectors.getFileRecords();
|
|
||||||
|
|
||||||
// Compute selection state and total items
|
// Compute selection state and total items
|
||||||
const getSelectionState = useCallback(() => {
|
const getSelectionState = useCallback(() => {
|
||||||
@ -85,7 +84,7 @@ export default function RightRail() {
|
|||||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
// Download selected files (or all if none selected)
|
// Download selected files (or all if none selected)
|
||||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||||
|
|
||||||
filesToDownload.forEach(file => {
|
filesToDownload.forEach(file => {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = URL.createObjectURL(file);
|
link.href = URL.createObjectURL(file);
|
||||||
@ -206,8 +205,8 @@ export default function RightRail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||||
<div
|
<div
|
||||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||||
aria-hidden={currentView === 'viewer'}
|
aria-hidden={currentView === 'viewer'}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||||
@ -358,14 +357,14 @@ export default function RightRail() {
|
|||||||
<LanguageSelector position="left-start" offset={6} compact />
|
<LanguageSelector position="left-start" offset={6} compact />
|
||||||
|
|
||||||
<Tooltip content={
|
<Tooltip content={
|
||||||
currentView === 'pageEditor'
|
currentView === 'pageEditor'
|
||||||
? t('rightRail.exportAll', 'Export PDF')
|
? t('rightRail.exportAll', 'Export PDF')
|
||||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||||
} position="left" offset={12} arrow>
|
} position="left" offset={12} arrow>
|
||||||
<div>
|
<div>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={handleExportAll}
|
onClick={handleExportAll}
|
||||||
disabled={currentView === 'viewer' || totalItems === 0}
|
disabled={currentView === 'viewer' || totalItems === 0}
|
||||||
|
@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
try { ro.disconnect(); } catch {}
|
try { ro.disconnect(); } catch { /* Ignore errors */ }
|
||||||
try { mo.disconnect(); } catch {}
|
try { mo.disconnect(); } catch { /* Ignore errors */ }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||||
*
|
*
|
||||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||||
* tool is currently active and offers a back button to return to the All Tools list.
|
* tool is currently active and offers a back button to return to the All Tools list.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Shows tool icon and name when a tool is selected
|
* - Shows tool icon and name when a tool is selected
|
||||||
* - Hover to reveal back arrow for returning to All Tools
|
* - Hover to reveal back arrow for returning to All Tools
|
||||||
@ -16,6 +16,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { ActionIcon } from '@mantine/core';
|
import { ActionIcon } from '@mantine/core';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
||||||
|
import { useSidebarNavigation } from '../../../hooks/useSidebarNavigation';
|
||||||
|
import { handleUnlessSpecialClick } from '../../../utils/clickHandlers';
|
||||||
import FitText from '../FitText';
|
import FitText from '../FitText';
|
||||||
import { Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
@ -26,8 +28,9 @@ interface ActiveToolButtonProps {
|
|||||||
|
|
||||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
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 { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||||
|
const { getHomeNavigation } = useSidebarNavigation();
|
||||||
|
|
||||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||||
const indicatorShouldShow = Boolean(
|
const indicatorShouldShow = Boolean(
|
||||||
@ -38,7 +41,6 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
|||||||
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
|
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
|
||||||
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
|
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
|
||||||
const [replayAnim, setReplayAnim] = useState<boolean>(false);
|
const [replayAnim, setReplayAnim] = useState<boolean>(false);
|
||||||
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
|
||||||
const [isBackHover, setIsBackHover] = useState<boolean>(false);
|
const [isBackHover, setIsBackHover] = useState<boolean>(false);
|
||||||
const prevKeyRef = useRef<string | null>(null);
|
const prevKeyRef = useRef<string | null>(null);
|
||||||
const collapseTimeoutRef = useRef<number | null>(null);
|
const collapseTimeoutRef = useRef<number | null>(null);
|
||||||
@ -71,11 +73,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
|||||||
replayRafRef.current = requestAnimationFrame(() => {
|
replayRafRef.current = requestAnimationFrame(() => {
|
||||||
setReplayAnim(true);
|
setReplayAnim(true);
|
||||||
});
|
});
|
||||||
setIsAnimating(true);
|
|
||||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||||
animTimeoutRef.current = window.setTimeout(() => {
|
animTimeoutRef.current = window.setTimeout(() => {
|
||||||
setReplayAnim(false);
|
setReplayAnim(false);
|
||||||
setIsAnimating(false);
|
|
||||||
animTimeoutRef.current = null;
|
animTimeoutRef.current = null;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
@ -84,10 +84,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
|||||||
clearTimers();
|
clearTimers();
|
||||||
setIndicatorTool(selectedTool);
|
setIndicatorTool(selectedTool);
|
||||||
setIndicatorVisible(true);
|
setIndicatorVisible(true);
|
||||||
setIsAnimating(true);
|
|
||||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||||
animTimeoutRef.current = window.setTimeout(() => {
|
animTimeoutRef.current = window.setTimeout(() => {
|
||||||
setIsAnimating(false);
|
|
||||||
animTimeoutRef.current = null;
|
animTimeoutRef.current = null;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
@ -95,11 +93,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
|||||||
const triggerCollapse = () => {
|
const triggerCollapse = () => {
|
||||||
clearTimers();
|
clearTimers();
|
||||||
setIndicatorVisible(false);
|
setIndicatorVisible(false);
|
||||||
setIsAnimating(true);
|
|
||||||
collapseTimeoutRef.current = window.setTimeout(() => {
|
collapseTimeoutRef.current = window.setTimeout(() => {
|
||||||
setIndicatorTool(null);
|
setIndicatorTool(null);
|
||||||
prevKeyRef.current = null;
|
prevKeyRef.current = null;
|
||||||
setIsAnimating(false);
|
|
||||||
collapseTimeoutRef.current = null;
|
collapseTimeoutRef.current = null;
|
||||||
}, 500); // match CSS transition duration
|
}, 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">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
|
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={getHomeNavigation().href}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
handleUnlessSpecialClick(e, () => {
|
||||||
|
setActiveButton('tools');
|
||||||
|
handleBackToTools();
|
||||||
|
});
|
||||||
|
}}
|
||||||
size={'xl'}
|
size={'xl'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onMouseEnter={() => setIsBackHover(true)}
|
onMouseEnter={() => setIsBackHover(true)}
|
||||||
onMouseLeave={() => setIsBackHover(false)}
|
onMouseLeave={() => setIsBackHover(false)}
|
||||||
onClick={() => {
|
|
||||||
setActiveButton('tools');
|
|
||||||
handleBackToTools();
|
|
||||||
}}
|
|
||||||
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
|
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
|
||||||
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
|
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="iconContainer">
|
<span className="iconContainer">
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { Box, Stack, Text } from '@mantine/core';
|
import { Box, Stack } from '@mantine/core';
|
||||||
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||||
import ToolButton from './toolPicker/ToolButton';
|
import ToolButton from './toolPicker/ToolButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -40,12 +40,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</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 }} />
|
<div aria-hidden style={{ height: 200 }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SearchResults;
|
export default SearchResults;
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
import ToolPicker from './ToolPicker';
|
import ToolPicker from './ToolPicker';
|
||||||
@ -8,12 +6,11 @@ import ToolRenderer from './ToolRenderer';
|
|||||||
import ToolSearch from './toolPicker/ToolSearch';
|
import ToolSearch from './toolPicker/ToolSearch';
|
||||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||||
import { Stack, ScrollArea } from '@mantine/core';
|
import { ScrollArea } from '@mantine/core';
|
||||||
|
|
||||||
// No props needed - component uses context
|
// No props needed - component uses context
|
||||||
|
|
||||||
export default function ToolPanel() {
|
export default function ToolPanel() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { toolPanelRef } = sidebarRefs;
|
const { toolPanelRef } = sidebarRefs;
|
||||||
@ -27,7 +24,6 @@ export default function ToolPanel() {
|
|||||||
filteredTools,
|
filteredTools,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
handleBackToTools
|
|
||||||
} = useToolWorkflow();
|
} = useToolWorkflow();
|
||||||
|
|
||||||
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
|
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
|
||||||
|
@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
|||||||
import { MantineProvider } from '@mantine/core';
|
import { MantineProvider } from '@mantine/core';
|
||||||
import AddPasswordSettings from './AddPasswordSettings';
|
import AddPasswordSettings from './AddPasswordSettings';
|
||||||
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
|
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
|
||||||
import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
|
|
||||||
|
|
||||||
// Mock useTranslation with predictable return values
|
// Mock useTranslation with predictable return values
|
||||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
const mockT = vi.fn((key: string) => `mock-${key}`);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import { Stack, PasswordInput, Select } from "@mantine/core";
|
||||||
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";
|
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface WatermarkTypeSettingsProps {
|
interface WatermarkTypeSettingsProps {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Stack, Text, TextInput } from "@mantine/core";
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||||
import { removeEmojis } from "../../../utils/textUtils";
|
import { removeEmojis } from "../../../utils/textUtils";
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -38,10 +38,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
|||||||
automationIcon,
|
automationIcon,
|
||||||
setAutomationIcon,
|
setAutomationIcon,
|
||||||
selectedTools,
|
selectedTools,
|
||||||
addTool,
|
|
||||||
removeTool,
|
removeTool,
|
||||||
updateTool,
|
updateTool,
|
||||||
hasUnsavedChanges,
|
|
||||||
canSaveAutomation,
|
canSaveAutomation,
|
||||||
getToolName,
|
getToolName,
|
||||||
getToolDefaultParameters
|
getToolDefaultParameters
|
||||||
@ -84,14 +82,6 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
|||||||
updateTool(selectedTools.length, newTool);
|
updateTool(selectedTools.length, newTool);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackClick = () => {
|
|
||||||
if (hasUnsavedChanges()) {
|
|
||||||
setUnsavedWarningOpen(true);
|
|
||||||
} else {
|
|
||||||
onBack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmBack = () => {
|
const handleConfirmBack = () => {
|
||||||
setUnsavedWarningOpen(false);
|
setUnsavedWarningOpen(false);
|
||||||
onBack();
|
onBack();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
@ -69,11 +69,11 @@ export default function AutomationEntry({
|
|||||||
|
|
||||||
const toolChain = operations.map((op, index) => (
|
const toolChain = operations.map((op, index) => (
|
||||||
<React.Fragment key={`${op}-${index}`}>
|
<React.Fragment key={`${op}-${index}`}>
|
||||||
<Text
|
<Text
|
||||||
component="span"
|
component="span"
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--mantine-primary-color-filled)',
|
color: 'var(--mantine-primary-color-filled)',
|
||||||
background: 'var(--mantine-primary-color-light)',
|
background: 'var(--mantine-primary-color-light)',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
@ -241,12 +241,12 @@ export default function AutomationEntry({
|
|||||||
|
|
||||||
// Show tooltip if there's a description OR operations to display
|
// Show tooltip if there's a description OR operations to display
|
||||||
const shouldShowTooltip = description || operations.length > 0;
|
const shouldShowTooltip = description || operations.length > 0;
|
||||||
|
|
||||||
return shouldShowTooltip ? (
|
return shouldShowTooltip ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={createTooltipContent()}
|
content={createTooltipContent()}
|
||||||
position="right"
|
position="right"
|
||||||
arrow={true}
|
arrow={true}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{boxContent}
|
{boxContent}
|
||||||
|
@ -20,11 +20,11 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
|||||||
const { selectedFiles } = useFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
const toolRegistry = useFlatToolRegistry();
|
const toolRegistry = useFlatToolRegistry();
|
||||||
const cleanup = useResourceCleanup();
|
const cleanup = useResourceCleanup();
|
||||||
|
|
||||||
// Progress tracking state
|
// Progress tracking state
|
||||||
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
|
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||||
|
|
||||||
// Use the operation hook's loading state
|
// Use the operation hook's loading state
|
||||||
const isExecuting = automateOperation?.isLoading || false;
|
const isExecuting = automateOperation?.isLoading || false;
|
||||||
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
|
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
|
||||||
@ -74,15 +74,15 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
|||||||
try {
|
try {
|
||||||
// Use the automateOperation.executeOperation to handle file consumption properly
|
// Use the automateOperation.executeOperation to handle file consumption properly
|
||||||
await automateOperation.executeOperation(
|
await automateOperation.executeOperation(
|
||||||
{
|
{
|
||||||
automationConfig: automation,
|
automationConfig: automation,
|
||||||
onStepStart: (stepIndex: number, operationName: string) => {
|
onStepStart: (stepIndex: number, _operationName: string) => {
|
||||||
setCurrentStepIndex(stepIndex);
|
setCurrentStepIndex(stepIndex);
|
||||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
|
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
|
onStepComplete: (stepIndex: number, _resultFiles: File[]) => {
|
||||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
|
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
|
||||||
));
|
));
|
||||||
@ -95,7 +95,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
|||||||
},
|
},
|
||||||
selectedFiles
|
selectedFiles
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark all as completed and reset current step
|
// Mark all as completed and reset current step
|
||||||
setCurrentStepIndex(-1);
|
setCurrentStepIndex(-1);
|
||||||
console.log(`✅ Automation completed successfully`);
|
console.log(`✅ Automation completed successfully`);
|
||||||
@ -118,20 +118,20 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
|||||||
case EXECUTION_STATUS.ERROR:
|
case EXECUTION_STATUS.ERROR:
|
||||||
return <span style={{ fontSize: 16, color: 'red' }}>✕</span>;
|
return <span style={{ fontSize: 16, color: 'red' }}>✕</span>;
|
||||||
case EXECUTION_STATUS.RUNNING:
|
case EXECUTION_STATUS.RUNNING:
|
||||||
return <div style={{
|
return <div style={{
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
border: '2px solid #ccc',
|
border: '2px solid #ccc',
|
||||||
borderTop: '2px solid #007bff',
|
borderTop: '2px solid #007bff',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
|
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
|
||||||
}} />;
|
}} />;
|
||||||
default:
|
default:
|
||||||
return <div style={{
|
return <div style={{
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
border: '2px solid #ccc',
|
border: '2px solid #ccc',
|
||||||
borderRadius: '50%'
|
borderRadius: '50%'
|
||||||
}} />;
|
}} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -170,8 +170,8 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
|||||||
{getStepIcon(step)}
|
{getStepIcon(step)}
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{
|
style={{
|
||||||
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
|
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
|
||||||
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
|
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
|
||||||
@ -220,4 +220,4 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
|||||||
</style>
|
</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
@ -32,7 +32,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [parameters, setParameters] = useState<any>({});
|
const [parameters, setParameters] = useState<any>({});
|
||||||
const [isValid, setIsValid] = useState(true);
|
|
||||||
|
|
||||||
// Get tool info from registry
|
// Get tool info from registry
|
||||||
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
|
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
|
||||||
@ -87,9 +86,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (isValid) {
|
onSave(parameters);
|
||||||
onSave(parameters);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -127,7 +124,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
|||||||
<Button
|
<Button
|
||||||
leftSection={<CheckIcon />}
|
leftSection={<CheckIcon />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!isValid}
|
|
||||||
>
|
>
|
||||||
{t('automate.config.save', 'Save Configuration')}
|
{t('automate.config.save', 'Save Configuration')}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Stack, Text, Checkbox } from "@mantine/core";
|
import { Stack, Checkbox } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
|
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||||
|
|
||||||
|
@ -22,13 +22,13 @@ import {
|
|||||||
OUTPUT_OPTIONS,
|
OUTPUT_OPTIONS,
|
||||||
FIT_OPTIONS
|
FIT_OPTIONS
|
||||||
} from "../../../constants/convertConstants";
|
} from "../../../constants/convertConstants";
|
||||||
import { FileId } from "../../../types/file";
|
import { StirlingFile } from "../../../types/fileContext";
|
||||||
|
|
||||||
interface ConvertSettingsProps {
|
interface ConvertSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ const ConvertSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterFilesByExtension = (extension: string) => {
|
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 => {
|
return files.filter(file => {
|
||||||
const fileExtension = detectFileExtension(file.name);
|
const fileExtension = detectFileExtension(file.name);
|
||||||
|
|
||||||
@ -143,21 +143,8 @@ const ConvertSettings = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFileSelection = (files: File[]) => {
|
const updateFileSelection = (files: StirlingFile[]) => {
|
||||||
// Map File objects to their actual IDs in FileContext
|
const fileIds = files.map(file => file.fileId);
|
||||||
const fileIds = files.map(file => {
|
|
||||||
// Find the file ID by matching file properties
|
|
||||||
const fileRecord = state.files.ids
|
|
||||||
.map(id => selectors.getFileRecord(id))
|
|
||||||
.find(record =>
|
|
||||||
record &&
|
|
||||||
record.name === file.name &&
|
|
||||||
record.size === file.size &&
|
|
||||||
record.lastModified === file.lastModified
|
|
||||||
);
|
|
||||||
return fileRecord?.id;
|
|
||||||
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
|
|
||||||
|
|
||||||
setSelectedFiles(fileIds);
|
setSelectedFiles(fileIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
interface ConvertToPdfaSettingsProps {
|
interface ConvertToPdfaSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
disabled?: boolean;
|
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 React from 'react';
|
||||||
import { Stack, Select, Text, Divider } from '@mantine/core';
|
import { Stack, Select, Divider } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LanguagePicker from './LanguagePicker';
|
import LanguagePicker from './LanguagePicker';
|
||||||
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
||||||
|
@ -8,11 +8,7 @@ interface RemoveCertificateSignSettingsProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
|
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
|
||||||
parameters,
|
|
||||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
|
||||||
disabled = false
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,4 +20,4 @@ const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RemoveCertificateSignSettings;
|
export default RemoveCertificateSignSettings;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Stack, Text, PasswordInput } from "@mantine/core";
|
import { Stack, PasswordInput } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";
|
import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";
|
||||||
|
|
||||||
|
@ -8,11 +8,7 @@ interface RepairSettingsProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RepairSettings: React.FC<RepairSettingsProps> = ({
|
const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
|
||||||
parameters,
|
|
||||||
onParameterChange,
|
|
||||||
disabled = false
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,4 +20,4 @@ const RepairSettings: React.FC<RepairSettingsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RepairSettings;
|
export default RepairSettings;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Text, Anchor } from "@mantine/core";
|
import { Text, Anchor } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FolderIcon from '@mui/icons-material/Folder';
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
|
|||||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||||
import { useAllFiles } from "../../../contexts/FileContext";
|
import { useAllFiles } from "../../../contexts/FileContext";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
import { StirlingFile } from "../../../types/fileContext";
|
||||||
|
|
||||||
export interface FileStatusIndicatorProps {
|
export interface FileStatusIndicatorProps {
|
||||||
selectedFiles?: File[];
|
selectedFiles?: StirlingFile[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
|
|||||||
}: FileStatusIndicatorProps) => {
|
}: FileStatusIndicatorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||||
const { files: workbenchFiles } = useAllFiles();
|
const { files: stirlingFileStubs } = useAllFiles();
|
||||||
const { loadRecentFiles } = useFileManager();
|
const { loadRecentFiles } = useFileManager();
|
||||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@ -27,7 +28,7 @@ const FileStatusIndicator = ({
|
|||||||
try {
|
try {
|
||||||
const recentFiles = await loadRecentFiles();
|
const recentFiles = await loadRecentFiles();
|
||||||
setHasRecentFiles(recentFiles.length > 0);
|
setHasRecentFiles(recentFiles.length > 0);
|
||||||
} catch (error) {
|
} catch {
|
||||||
setHasRecentFiles(false);
|
setHasRecentFiles(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are no files in the workbench
|
// 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 no recent files, show upload button
|
||||||
if (!hasRecentFiles) {
|
if (!hasRecentFiles) {
|
||||||
return (
|
return (
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileStatusIndicator from './FileStatusIndicator';
|
import FileStatusIndicator from './FileStatusIndicator';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
export interface FilesToolStepProps {
|
export interface FilesToolStepProps {
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Button, Group, Stack } from "@mantine/core";
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import UndoIcon from "@mui/icons-material/Undo";
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import React from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||||
|
|
||||||
export interface SuggestedToolsSectionProps {}
|
|
||||||
|
|
||||||
export function SuggestedToolsSection(): React.ReactElement {
|
export function SuggestedToolsSection(): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const suggestedTools = useSuggestedTools();
|
const suggestedTools = useSuggestedTools();
|
||||||
@ -21,20 +19,25 @@ export function SuggestedToolsSection(): React.ReactElement {
|
|||||||
{suggestedTools.map((tool) => {
|
{suggestedTools.map((tool) => {
|
||||||
const IconComponent = tool.icon;
|
const IconComponent = tool.icon;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Anchor
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
p="sm"
|
href={tool.href}
|
||||||
withBorder
|
onClick={tool.onClick}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
onClick={tool.navigate}
|
|
||||||
>
|
>
|
||||||
<Group gap="xs">
|
<Card
|
||||||
<IconComponent fontSize="small" />
|
p="sm"
|
||||||
<Text size="sm" fw={500}>
|
withBorder
|
||||||
{tool.title}
|
style={{ cursor: 'pointer' }}
|
||||||
</Text>
|
>
|
||||||
</Group>
|
<Group gap="xs">
|
||||||
</Card>
|
<IconComponent fontSize="small" />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{tool.title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Anchor>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
|
import { Text, Stack, Flex, Divider } from '@mantine/core';
|
||||||
import LocalIcon from '../../shared/LocalIcon';
|
import LocalIcon from '../../shared/LocalIcon';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
import { TooltipTip } from '../../../types/tips';
|
import { TooltipTip } from '../../../types/tips';
|
||||||
|
@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
|||||||
import OperationButton from './OperationButton';
|
import OperationButton from './OperationButton';
|
||||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
@ -80,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Middle Steps */}
|
{/* Middle Steps */}
|
||||||
{config.steps.map((stepConfig, index) =>
|
{config.steps.map((stepConfig) =>
|
||||||
steps.create(stepConfig.title, {
|
steps.create(stepConfig.title, {
|
||||||
isVisible: stepConfig.isVisible,
|
isVisible: stepConfig.isVisible,
|
||||||
isCollapsed: stepConfig.isCollapsed,
|
isCollapsed: stepConfig.isCollapsed,
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import { Box } from '@mantine/core';
|
||||||
import { Box, Stack } from '@mantine/core';
|
|
||||||
import ToolButton from '../toolPicker/ToolButton';
|
import ToolButton from '../toolPicker/ToolButton';
|
||||||
import SubcategoryHeader from './SubcategoryHeader';
|
import SubcategoryHeader from './SubcategoryHeader';
|
||||||
|
|
||||||
|
@ -8,11 +8,7 @@ interface SingleLargePageSettingsProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
|
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
|
||||||
parameters,
|
|
||||||
onParameterChange,
|
|
||||||
disabled = false
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,4 +20,4 @@ const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SingleLargePageSettings;
|
export default SingleLargePageSettings;
|
||||||
|
@ -2,6 +2,8 @@ import React from "react";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Tooltip } from "../../shared/Tooltip";
|
import { Tooltip } from "../../shared/Tooltip";
|
||||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||||
|
import { useToolNavigation } from "../../../hooks/useToolNavigation";
|
||||||
|
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
||||||
import FitText from "../../shared/FitText";
|
import FitText from "../../shared/FitText";
|
||||||
|
|
||||||
interface ToolButtonProps {
|
interface ToolButtonProps {
|
||||||
@ -14,6 +16,8 @@ interface ToolButtonProps {
|
|||||||
|
|
||||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
||||||
const isUnavailable = !tool.component && !tool.link;
|
const isUnavailable = !tool.component && !tool.link;
|
||||||
|
const { getToolNavigation } = useToolNavigation();
|
||||||
|
|
||||||
const handleClick = (id: string) => {
|
const handleClick = (id: string) => {
|
||||||
if (isUnavailable) return;
|
if (isUnavailable) return;
|
||||||
if (tool.link) {
|
if (tool.link) {
|
||||||
@ -25,32 +29,84 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
|
|||||||
onSelect(id);
|
onSelect(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get navigation props for URL support
|
||||||
|
const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null;
|
||||||
|
|
||||||
const tooltipContent = isUnavailable
|
const tooltipContent = isUnavailable
|
||||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||||
: tool.description;
|
: 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 (
|
return (
|
||||||
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
|
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
|
||||||
<Button
|
{buttonElement}
|
||||||
variant={isSelected ? "filled" : "subtle"}
|
|
||||||
onClick={()=> handleClick(id)}
|
|
||||||
size="sm"
|
|
||||||
radius="md"
|
|
||||||
fullWidth
|
|
||||||
justify="flex-start"
|
|
||||||
className="tool-button"
|
|
||||||
aria-disabled={isUnavailable}
|
|
||||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
|
|
||||||
>
|
|
||||||
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
|
|
||||||
<FitText
|
|
||||||
text={tool.name}
|
|
||||||
lines={1}
|
|
||||||
minimumFontScale={0.8}
|
|
||||||
as="span"
|
|
||||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -126,7 +126,7 @@ const ToolSearch = ({
|
|||||||
key={id}
|
key={id}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onToolSelect && onToolSelect(id);
|
onToolSelect?.(id);
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
|
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
|
||||||
|
@ -8,11 +8,7 @@ interface UnlockPdfFormsSettingsProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
|
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = (_) => {
|
||||||
parameters,
|
|
||||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
|
||||||
disabled = false
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,4 +20,4 @@ const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UnlockPdfFormsSettings;
|
export default UnlockPdfFormsSettings;
|
||||||
|
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 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 { useTranslation } from "react-i18next";
|
||||||
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
||||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||||
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||||
import LastPageIcon from "@mui/icons-material/LastPage";
|
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 ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
||||||
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { useLocalStorage } from "@mantine/hooks";
|
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
import { useFileState } from "../../contexts/FileContext";
|
||||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||||
|
import { isFileObject } from "../../types/fileContext";
|
||||||
import { FileId } from "../../types/file";
|
import { FileId } from "../../types/file";
|
||||||
|
|
||||||
|
|
||||||
@ -141,8 +140,6 @@ export interface ViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Viewer = ({
|
const Viewer = ({
|
||||||
sidebarsVisible,
|
|
||||||
setSidebarsVisible,
|
|
||||||
onClose,
|
onClose,
|
||||||
previewFile,
|
previewFile,
|
||||||
}: ViewerProps) => {
|
}: ViewerProps) => {
|
||||||
@ -151,13 +148,7 @@ const Viewer = ({
|
|||||||
|
|
||||||
// Get current file from FileContext
|
// Get current file from FileContext
|
||||||
const { selectors } = useFileState();
|
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();
|
const activeFiles = selectors.getFiles();
|
||||||
|
|
||||||
// Tab management for multiple files
|
// Tab management for multiple files
|
||||||
@ -201,7 +192,7 @@ const Viewer = ({
|
|||||||
const effectiveFile = React.useMemo(() => {
|
const effectiveFile = React.useMemo(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
// Validate the preview file
|
// Validate the preview file
|
||||||
if (!(previewFile instanceof File)) {
|
if (!isFileObject(previewFile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,7 +396,7 @@ const Viewer = ({
|
|||||||
// Start progressive preloading after a short delay
|
// Start progressive preloading after a short delay
|
||||||
setTimeout(() => startProgressivePreload(), 100);
|
setTimeout(() => startProgressivePreload(), 100);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPageImages([]);
|
setPageImages([]);
|
||||||
setNumPages(0);
|
setNumPages(0);
|
||||||
|
@ -19,7 +19,10 @@ import {
|
|||||||
FileContextStateValue,
|
FileContextStateValue,
|
||||||
FileContextActionsValue,
|
FileContextActionsValue,
|
||||||
FileContextActions,
|
FileContextActions,
|
||||||
FileRecord
|
FileId,
|
||||||
|
StirlingFileStub,
|
||||||
|
StirlingFile,
|
||||||
|
createStirlingFile
|
||||||
} from '../types/fileContext';
|
} from '../types/fileContext';
|
||||||
|
|
||||||
// Import modular components
|
// Import modular components
|
||||||
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
|
|||||||
import { FileLifecycleManager } from './file/lifecycle';
|
import { FileLifecycleManager } from './file/lifecycle';
|
||||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||||
import { FileId } from '../types/file';
|
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
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
|
// Inner provider component that has access to IndexedDB
|
||||||
function FileContextInner({
|
function FileContextInner({
|
||||||
children,
|
children,
|
||||||
enableUrlSync = true,
|
|
||||||
enablePersistence = true
|
enablePersistence = true
|
||||||
}: FileContextProviderProps) {
|
}: FileContextProviderProps) {
|
||||||
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
||||||
@ -79,7 +80,7 @@ function FileContextInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// File operations using unified addFiles helper with persistence
|
// 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);
|
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
|
|
||||||
// Auto-select the newly added files if requested
|
// 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]);
|
}, [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);
|
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);
|
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
|
|
||||||
// Auto-select the newly added files if requested
|
// Auto-select the newly added files if requested
|
||||||
@ -114,7 +115,7 @@ function FileContextInner({
|
|||||||
selectFiles(result);
|
selectFiles(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.map(({ file }) => file);
|
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Action creators
|
// Action creators
|
||||||
@ -122,42 +123,21 @@ function FileContextInner({
|
|||||||
|
|
||||||
// Helper functions for pinned files
|
// Helper functions for pinned files
|
||||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
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]);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||||
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||||
}, [indexedDB]);
|
}, [indexedDB]);
|
||||||
|
|
||||||
// Helper to find FileId from File object
|
// File pinning functions - use StirlingFile directly
|
||||||
const findFileId = useCallback((file: File): FileId | undefined => {
|
const pinFileWrapper = useCallback((file: StirlingFile) => {
|
||||||
return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
|
baseActions.pinFile(file.fileId);
|
||||||
const storedFile = filesRef.current.get(id);
|
}, [baseActions]);
|
||||||
return storedFile &&
|
|
||||||
storedFile.name === file.name &&
|
|
||||||
storedFile.size === file.size &&
|
|
||||||
storedFile.lastModified === file.lastModified;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// File-to-ID wrapper functions for pinning
|
const unpinFileWrapper = useCallback((file: StirlingFile) => {
|
||||||
const pinFileWrapper = useCallback((file: File) => {
|
baseActions.unpinFile(file.fileId);
|
||||||
const fileId = findFileId(file);
|
}, [baseActions]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Complete actions object
|
// Complete actions object
|
||||||
const actions = useMemo<FileContextActions>(() => ({
|
const actions = useMemo<FileContextActions>(() => ({
|
||||||
@ -178,8 +158,8 @@ function FileContextInner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
|
||||||
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
|
||||||
reorderFiles: (orderedFileIds: FileId[]) => {
|
reorderFiles: (orderedFileIds: FileId[]) => {
|
||||||
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
||||||
},
|
},
|
||||||
@ -303,7 +283,7 @@ export {
|
|||||||
useFileSelection,
|
useFileSelection,
|
||||||
useFileManagement,
|
useFileManagement,
|
||||||
useFileUI,
|
useFileUI,
|
||||||
useFileRecord,
|
useStirlingFileStub,
|
||||||
useAllFiles,
|
useAllFiles,
|
||||||
useSelectedFiles,
|
useSelectedFiles,
|
||||||
// Primary API hooks for tools
|
// Primary API hooks for tools
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
import { downloadFiles } from '../utils/downloadUtils';
|
import { downloadFiles } from '../utils/downloadUtils';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/file';
|
||||||
import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
|
import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
import { fileStorage, StoredFile } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/file';
|
||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
@ -64,7 +64,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
|||||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||||
|
|
||||||
// Store in IndexedDB
|
// Store in IndexedDB
|
||||||
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
await fileStorage.storeFile(file, fileId, thumbnail);
|
||||||
|
|
||||||
// Cache the file object for immediate reuse
|
// Cache the file object for immediate reuse
|
||||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||||
|
@ -103,7 +103,7 @@ const NavigationActionsContext = createContext<NavigationContextActionsValue | u
|
|||||||
export const NavigationProvider: React.FC<{
|
export const NavigationProvider: React.FC<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
enableUrlSync?: boolean;
|
enableUrlSync?: boolean;
|
||||||
}> = ({ children, enableUrlSync = true }) => {
|
}> = ({ children }) => {
|
||||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||||
const toolRegistry = useFlatToolRegistry();
|
const toolRegistry = useFlatToolRegistry();
|
||||||
|
|
||||||
|
@ -89,6 +89,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
|||||||
clearToolSelection: () => void;
|
clearToolSelection: () => void;
|
||||||
|
|
||||||
// Tool Reset Actions
|
// Tool Reset Actions
|
||||||
|
toolResetFunctions: Record<string, () => void>;
|
||||||
registerToolReset: (toolId: string, resetFunction: () => void) => void;
|
registerToolReset: (toolId: string, resetFunction: () => void) => void;
|
||||||
resetTool: (toolId: string) => void;
|
resetTool: (toolId: string) => void;
|
||||||
|
|
||||||
@ -258,6 +259,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
clearToolSelection: () => actions.setSelectedTool(null),
|
clearToolSelection: () => actions.setSelectedTool(null),
|
||||||
|
|
||||||
// Tool Reset Actions
|
// Tool Reset Actions
|
||||||
|
toolResetFunctions,
|
||||||
registerToolReset,
|
registerToolReset,
|
||||||
resetTool,
|
resetTool,
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
|
|||||||
import {
|
import {
|
||||||
FileContextState,
|
FileContextState,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileRecord
|
StirlingFileStub
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
|
|||||||
function processFileSwap(
|
function processFileSwap(
|
||||||
state: FileContextState,
|
state: FileContextState,
|
||||||
filesToRemove: FileId[],
|
filesToRemove: FileId[],
|
||||||
filesToAdd: FileRecord[]
|
filesToAdd: StirlingFileStub[]
|
||||||
): FileContextState {
|
): FileContextState {
|
||||||
// Only remove unpinned files
|
// Only remove unpinned files
|
||||||
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||||
@ -70,11 +70,11 @@ function processFileSwap(
|
|||||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'ADD_FILES': {
|
case 'ADD_FILES': {
|
||||||
const { fileRecords } = action.payload;
|
const { stirlingFileStubs } = action.payload;
|
||||||
const newIds: FileId[] = [];
|
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)
|
// Only add if not already present (dedupe by stable ID)
|
||||||
if (!newById[record.id]) {
|
if (!newById[record.id]) {
|
||||||
newIds.push(record.id);
|
newIds.push(record.id);
|
||||||
@ -235,13 +235,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'CONSUME_FILES': {
|
case 'CONSUME_FILES': {
|
||||||
const { inputFileIds, outputFileRecords } = action.payload;
|
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||||
return processFileSwap(state, inputFileIds, outputFileRecords);
|
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'UNDO_CONSUME_FILES': {
|
case 'UNDO_CONSUME_FILES': {
|
||||||
const { inputFileRecords, outputFileIds } = action.payload;
|
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||||
return processFileSwap(state, outputFileIds, inputFileRecords);
|
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RESET_CONTEXT': {
|
case 'RESET_CONTEXT': {
|
||||||
|
@ -3,19 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FileRecord,
|
StirlingFileStub,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
toFileRecord,
|
toStirlingFileStub,
|
||||||
createFileId,
|
createFileId,
|
||||||
createQuickKey
|
createQuickKey
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
import { FileId, FileMetadata } from '../../types/file';
|
import { FileId, FileMetadata } from '../../types/file';
|
||||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||||
import { FileLifecycleManager } from './lifecycle';
|
import { FileLifecycleManager } from './lifecycle';
|
||||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
import { buildQuickKeySet } from './fileSelectors';
|
||||||
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
import { extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
|
||||||
import { extractFileHistory, extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
|
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
@ -110,8 +109,8 @@ export async function addFiles(
|
|||||||
await addFilesMutex.lock();
|
await addFilesMutex.lock();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileRecords: FileRecord[] = [];
|
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||||
const addedFiles: AddedFile[] = [];
|
const addedFiles: AddedFile[] = [];
|
||||||
|
|
||||||
// Build quickKey lookup from existing files for deduplication
|
// Build quickKey lookup from existing files for deduplication
|
||||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||||
@ -164,7 +163,7 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create record with immediate thumbnail and page metadata
|
// Create record with immediate thumbnail and page metadata
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
@ -188,15 +187,15 @@ export async function addFiles(
|
|||||||
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
||||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||||
// Basic metadata found, dispatch update to trigger re-render
|
// Basic metadata found, dispatch update to trigger re-render
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_FILE_RECORD',
|
type: 'UPDATE_FILE_RECORD',
|
||||||
payload: {
|
payload: {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
updates: {
|
updates: {
|
||||||
versionNumber: updatedRecord.versionNumber,
|
versionNumber: updatedRecord.versionNumber,
|
||||||
toolHistory: updatedRecord.toolHistory
|
toolHistory: updatedRecord.toolHistory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@ -204,7 +203,7 @@ export async function addFiles(
|
|||||||
});
|
});
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
stirlingFileStubs.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -225,7 +224,7 @@ export async function addFiles(
|
|||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
@ -249,15 +248,15 @@ export async function addFiles(
|
|||||||
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
||||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||||
// Basic metadata found, dispatch update to trigger re-render
|
// Basic metadata found, dispatch update to trigger re-render
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_FILE_RECORD',
|
type: 'UPDATE_FILE_RECORD',
|
||||||
payload: {
|
payload: {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
updates: {
|
updates: {
|
||||||
versionNumber: updatedRecord.versionNumber,
|
versionNumber: updatedRecord.versionNumber,
|
||||||
toolHistory: updatedRecord.toolHistory
|
toolHistory: updatedRecord.toolHistory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@ -265,7 +264,7 @@ export async function addFiles(
|
|||||||
});
|
});
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
stirlingFileStubs.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -293,7 +292,7 @@ export async function addFiles(
|
|||||||
|
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
|
|
||||||
// Generate processedFile metadata for stored files
|
// Generate processedFile metadata for stored files
|
||||||
let pageCount: number = 1;
|
let pageCount: number = 1;
|
||||||
@ -343,15 +342,15 @@ export async function addFiles(
|
|||||||
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
||||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||||
// Basic metadata found, dispatch update to trigger re-render
|
// Basic metadata found, dispatch update to trigger re-render
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_FILE_RECORD',
|
type: 'UPDATE_FILE_RECORD',
|
||||||
payload: {
|
payload: {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
updates: {
|
updates: {
|
||||||
versionNumber: updatedRecord.versionNumber,
|
versionNumber: updatedRecord.versionNumber,
|
||||||
toolHistory: updatedRecord.toolHistory
|
toolHistory: updatedRecord.toolHistory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@ -359,7 +358,7 @@ export async function addFiles(
|
|||||||
});
|
});
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
stirlingFileStubs.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
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
|
// Dispatch ADD_FILES action if we have new files
|
||||||
if (fileRecords.length > 0) {
|
if (stirlingFileStubs.length > 0) {
|
||||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedFiles;
|
return addedFiles;
|
||||||
@ -386,7 +385,7 @@ export async function addFiles(
|
|||||||
async function processFilesIntoRecords(
|
async function processFilesIntoRecords(
|
||||||
files: File[],
|
files: File[],
|
||||||
filesRef: React.MutableRefObject<Map<FileId, 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(
|
return Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
@ -405,7 +404,7 @@ async function processFilesIntoRecords(
|
|||||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
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) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
}
|
}
|
||||||
@ -418,7 +417,7 @@ async function processFilesIntoRecords(
|
|||||||
if (file.type.includes('pdf')) {
|
if (file.type.includes('pdf')) {
|
||||||
try {
|
try {
|
||||||
const updatedRecord = await extractBasicFileMetadata(file, record);
|
const updatedRecord = await extractBasicFileMetadata(file, record);
|
||||||
|
|
||||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||||
// Update the record directly with basic metadata
|
// Update the record directly with basic metadata
|
||||||
Object.assign(record, {
|
Object.assign(record, {
|
||||||
@ -440,10 +439,10 @@ async function processFilesIntoRecords(
|
|||||||
* Helper function to persist files to IndexedDB
|
* Helper function to persist files to IndexedDB
|
||||||
*/
|
*/
|
||||||
async function persistFilesToIndexedDB(
|
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> }
|
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||||
try {
|
try {
|
||||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -458,7 +457,6 @@ async function persistFilesToIndexedDB(
|
|||||||
export async function consumeFiles(
|
export async function consumeFiles(
|
||||||
inputFileIds: FileId[],
|
inputFileIds: FileId[],
|
||||||
outputFiles: File[],
|
outputFiles: File[],
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>,
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; markFileAsProcessed: (fileId: FileId) => Promise<boolean> } | null
|
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`);
|
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||||
|
|
||||||
// Process output files with thumbnails and metadata
|
// 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)
|
// Mark input files as processed in IndexedDB (no longer leaf nodes)
|
||||||
if (indexedDB) {
|
if (indexedDB) {
|
||||||
await Promise.all([
|
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch the consume action
|
// Dispatch the consume action
|
||||||
@ -504,21 +476,20 @@ export async function consumeFiles(
|
|||||||
type: 'CONSUME_FILES',
|
type: 'CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputFileIds,
|
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 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
|
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||||
*/
|
*/
|
||||||
async function restoreFilesAndCleanup(
|
async function restoreFilesAndCleanup(
|
||||||
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
|
||||||
fileIdsToRemove: FileId[],
|
fileIdsToRemove: FileId[],
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
@ -541,7 +512,7 @@ async function restoreFilesAndCleanup(
|
|||||||
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the file to filesRef
|
// Restore the file to filesRef
|
||||||
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||||
filesRef.current.set(record.id, file);
|
filesRef.current.set(record.id, file);
|
||||||
@ -556,7 +527,7 @@ async function restoreFilesAndCleanup(
|
|||||||
throw error; // Re-throw to trigger rollback
|
throw error; // Re-throw to trigger rollback
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute all IndexedDB operations
|
// Execute all IndexedDB operations
|
||||||
await Promise.all(indexedDBPromises);
|
await Promise.all(indexedDBPromises);
|
||||||
}
|
}
|
||||||
@ -567,28 +538,27 @@ async function restoreFilesAndCleanup(
|
|||||||
*/
|
*/
|
||||||
export async function undoConsumeFiles(
|
export async function undoConsumeFiles(
|
||||||
inputFiles: File[],
|
inputFiles: File[],
|
||||||
inputFileRecords: FileRecord[],
|
inputStirlingFileStubs: StirlingFileStub[],
|
||||||
outputFileIds: FileId[],
|
outputFileIds: FileId[],
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>,
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
): Promise<void> {
|
): 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
|
// Validate inputs
|
||||||
if (inputFiles.length !== inputFileRecords.length) {
|
if (inputFiles.length !== inputStirlingFileStubs.length) {
|
||||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a backup of current filesRef state for rollback
|
// Create a backup of current filesRef state for rollback
|
||||||
const backupFilesRef = new Map(filesRef.current);
|
const backupFilesRef = new Map(filesRef.current);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prepare files to restore
|
// Prepare files to restore
|
||||||
const filesToRestore = inputFiles.map((file, index) => ({
|
const filesToRestore = inputFiles.map((file, index) => ({
|
||||||
file,
|
file,
|
||||||
record: inputFileRecords[index]
|
record: inputStirlingFileStubs[index]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Restore input files and clean up output files
|
// Restore input files and clean up output files
|
||||||
@ -603,13 +573,12 @@ export async function undoConsumeFiles(
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: 'UNDO_CONSUME_FILES',
|
type: 'UNDO_CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputFileRecords,
|
inputStirlingFileStubs,
|
||||||
outputFileIds
|
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) {
|
} catch (error) {
|
||||||
// Rollback filesRef to previous state
|
// Rollback filesRef to previous state
|
||||||
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
FileContextStateValue,
|
FileContextStateValue,
|
||||||
FileContextActionsValue
|
FileContextActionsValue
|
||||||
} from './contexts';
|
} from './contexts';
|
||||||
import { FileRecord } from '../../types/fileContext';
|
import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
|
|||||||
/**
|
/**
|
||||||
* Hook for current/primary file (first in list)
|
* 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 { state, selectors } = useFileState();
|
||||||
|
|
||||||
const primaryFileId = state.files.ids[0];
|
const primaryFileId = state.files.ids[0];
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||||
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
|
||||||
}), [primaryFileId, selectors]);
|
}), [primaryFileId, selectors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ export function useFileManagement() {
|
|||||||
addFiles: actions.addFiles,
|
addFiles: actions.addFiles,
|
||||||
removeFiles: actions.removeFiles,
|
removeFiles: actions.removeFiles,
|
||||||
clearAllFiles: actions.clearAllFiles,
|
clearAllFiles: actions.clearAllFiles,
|
||||||
updateFileRecord: actions.updateFileRecord,
|
updateStirlingFileStub: actions.updateStirlingFileStub,
|
||||||
reorderFiles: actions.reorderFiles
|
reorderFiles: actions.reorderFiles
|
||||||
}), [actions]);
|
}), [actions]);
|
||||||
}
|
}
|
||||||
@ -111,24 +111,24 @@ export function useFileUI() {
|
|||||||
/**
|
/**
|
||||||
* Hook for specific file by ID (optimized for individual file access)
|
* 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();
|
const { selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
file: selectors.getFile(fileId),
|
file: selectors.getFile(fileId),
|
||||||
record: selectors.getFileRecord(fileId)
|
record: selectors.getStirlingFileStub(fileId)
|
||||||
}), [fileId, selectors]);
|
}), [fileId, selectors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
* 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();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getFiles(),
|
files: selectors.getFiles(),
|
||||||
records: selectors.getFileRecords(),
|
records: selectors.getStirlingFileStubs(),
|
||||||
fileIds: state.files.ids
|
fileIds: state.files.ids
|
||||||
}), [state.files.ids, selectors]);
|
}), [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)
|
* 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();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getSelectedFiles(),
|
files: selectors.getSelectedFiles(),
|
||||||
records: selectors.getSelectedFileRecords(),
|
records: selectors.getSelectedStirlingFileStubs(),
|
||||||
fileIds: state.ui.selectedFileIds
|
fileIds: state.ui.selectedFileIds
|
||||||
}), [state.ui.selectedFileIds, selectors]);
|
}), [state.ui.selectedFileIds, selectors]);
|
||||||
}
|
}
|
||||||
@ -166,9 +166,9 @@ export function useFileContext() {
|
|||||||
addFiles: actions.addFiles,
|
addFiles: actions.addFiles,
|
||||||
consumeFiles: actions.consumeFiles,
|
consumeFiles: actions.consumeFiles,
|
||||||
undoConsumeFiles: actions.undoConsumeFiles,
|
undoConsumeFiles: actions.undoConsumeFiles,
|
||||||
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
|
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
||||||
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
|
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
||||||
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
|
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
|
||||||
|
|
||||||
// File ID lookup
|
// File ID lookup
|
||||||
findFileId: (file: File) => {
|
findFileId: (file: File) => {
|
||||||
|
@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
import {
|
import {
|
||||||
FileRecord,
|
StirlingFileStub,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
FileContextSelectors
|
FileContextSelectors,
|
||||||
|
StirlingFile,
|
||||||
|
createStirlingFile
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,16 +19,24 @@ export function createFileSelectors(
|
|||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): FileContextSelectors {
|
): FileContextSelectors {
|
||||||
return {
|
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[]) => {
|
getFiles: (ids?: FileId[]) => {
|
||||||
const currentIds = ids || stateRef.current.files.ids;
|
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;
|
const currentIds = ids || stateRef.current.files.ids;
|
||||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||||
},
|
},
|
||||||
@ -35,11 +45,14 @@ export function createFileSelectors(
|
|||||||
|
|
||||||
getSelectedFiles: () => {
|
getSelectedFiles: () => {
|
||||||
return stateRef.current.ui.selectedFileIds
|
return stateRef.current.ui.selectedFileIds
|
||||||
.map(id => filesRef.current.get(id))
|
.map(id => {
|
||||||
.filter(Boolean) as File[];
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createStirlingFile(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as StirlingFile[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getSelectedFileRecords: () => {
|
getSelectedStirlingFileStubs: () => {
|
||||||
return stateRef.current.ui.selectedFileIds
|
return stateRef.current.ui.selectedFileIds
|
||||||
.map(id => stateRef.current.files.byId[id])
|
.map(id => stateRef.current.files.byId[id])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -52,26 +65,21 @@ export function createFileSelectors(
|
|||||||
|
|
||||||
getPinnedFiles: () => {
|
getPinnedFiles: () => {
|
||||||
return Array.from(stateRef.current.pinnedFiles)
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
.map(id => filesRef.current.get(id))
|
.map(id => {
|
||||||
.filter(Boolean) as File[];
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createStirlingFile(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as StirlingFile[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getPinnedFileRecords: () => {
|
getPinnedStirlingFileStubs: () => {
|
||||||
return Array.from(stateRef.current.pinnedFiles)
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
.map(id => stateRef.current.files.byId[id])
|
.map(id => stateRef.current.files.byId[id])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
},
|
},
|
||||||
|
|
||||||
isFilePinned: (file: File) => {
|
isFilePinned: (file: StirlingFile) => {
|
||||||
// Find FileId by matching File object properties
|
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stable signature for effects - prevents unnecessary re-renders
|
// Stable signature for effects - prevents unnecessary re-renders
|
||||||
@ -90,9 +98,9 @@ export function createFileSelectors(
|
|||||||
/**
|
/**
|
||||||
* Helper for building quickKey sets for deduplication
|
* 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>();
|
const quickKeys = new Set<string>();
|
||||||
Object.values(fileRecords).forEach(record => {
|
Object.values(stirlingFileStubs).forEach(record => {
|
||||||
if (record.quickKey) {
|
if (record.quickKey) {
|
||||||
quickKeys.add(record.quickKey);
|
quickKeys.add(record.quickKey);
|
||||||
}
|
}
|
||||||
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
|
|||||||
export function getPrimaryFile(
|
export function getPrimaryFile(
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): { file?: File; record?: FileRecord } {
|
): { file?: File; record?: StirlingFileStub } {
|
||||||
const primaryFileId = stateRef.current.files.ids[0];
|
const primaryFileId = stateRef.current.files.ids[0];
|
||||||
if (!primaryFileId) return {};
|
if (!primaryFileId) return {};
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FileId } from '../../types/file';
|
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';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ export class FileLifecycleManager {
|
|||||||
this.blobUrls.forEach(url => {
|
this.blobUrls.forEach(url => {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore revocation errors
|
// Ignore revocation errors
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -134,7 +134,7 @@ export class FileLifecycleManager {
|
|||||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(record.thumbnailUrl);
|
URL.revokeObjectURL(record.thumbnailUrl);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore revocation errors
|
// Ignore revocation errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,18 +142,18 @@ export class FileLifecycleManager {
|
|||||||
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(record.blobUrl);
|
URL.revokeObjectURL(record.blobUrl);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore revocation errors
|
// Ignore revocation errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up processed file thumbnails
|
// Clean up processed file thumbnails
|
||||||
if (record.processedFile?.pages) {
|
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:')) {
|
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(page.thumbnail);
|
URL.revokeObjectURL(page.thumbnail);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore revocation errors
|
// Ignore revocation errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,7 +166,7 @@ export class FileLifecycleManager {
|
|||||||
/**
|
/**
|
||||||
* Update file record with race condition guards
|
* 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)
|
// Guard against updating removed files (race condition protection)
|
||||||
if (!this.filesRef.current.has(fileId)) {
|
if (!this.filesRef.current.has(fileId)) {
|
||||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${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 SingleLargePage from "../tools/SingleLargePage";
|
||||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||||
|
import Flatten from "../tools/Flatten";
|
||||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
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 { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
|
||||||
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||||
|
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
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 OCRSettings from "../components/tools/ocr/OCRSettings";
|
||||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||||
|
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||||
import { ToolId } from "../types/toolId";
|
import { ToolId } from "../types/toolId";
|
||||||
|
|
||||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||||
@ -198,10 +201,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
flatten: {
|
flatten: {
|
||||||
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.flatten.title", "Flatten"),
|
name: t("home.flatten.title", "Flatten"),
|
||||||
component: null,
|
component: Flatten,
|
||||||
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
|
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["flatten"],
|
||||||
|
operationConfig: flattenOperationConfig,
|
||||||
|
settingsComponent: FlattenSettings,
|
||||||
},
|
},
|
||||||
"unlock-pdf-forms": {
|
"unlock-pdf-forms": {
|
||||||
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
@ -355,6 +362,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||||
maxFiles: -1,
|
maxFiles: -1,
|
||||||
|
urlPath: '/pdf-to-single-page',
|
||||||
endpoints: ["pdf-to-single-page"],
|
endpoints: ["pdf-to-single-page"],
|
||||||
operationConfig: singleLargePageOperationConfig,
|
operationConfig: singleLargePageOperationConfig,
|
||||||
},
|
},
|
||||||
@ -681,6 +689,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||||
subcategoryId: SubcategoryId.GENERAL,
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
maxFiles: -1,
|
maxFiles: -1,
|
||||||
|
urlPath: '/ocr-pdf',
|
||||||
operationConfig: ocrOperationConfig,
|
operationConfig: ocrOperationConfig,
|
||||||
settingsComponent: OCRSettings,
|
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 { renderHook } from '@testing-library/react';
|
||||||
import { useAddPasswordOperation } from './useAddPasswordOperation';
|
import { useAddPasswordOperation } from './useAddPasswordOperation';
|
||||||
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
|
import type { AddPasswordFullParameters } from './useAddPasswordParameters';
|
||||||
|
|
||||||
// Mock the useToolOperation hook
|
// Mock the useToolOperation hook
|
||||||
vi.mock('../shared/useToolOperation', async () => {
|
vi.mock('../shared/useToolOperation', async () => {
|
||||||
|
@ -44,9 +44,9 @@ export function useSavedAutomations() {
|
|||||||
const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => {
|
const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => {
|
||||||
try {
|
try {
|
||||||
const { automationStorage } = await import('../../../services/automationStorage');
|
const { automationStorage } = await import('../../../services/automationStorage');
|
||||||
|
|
||||||
// Map suggested automation icons to MUI icon keys
|
// 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
|
// Check the automation ID or name to determine the appropriate icon
|
||||||
switch (suggestedAutomation.id) {
|
switch (suggestedAutomation.id) {
|
||||||
case 'secure-pdf-ingestion':
|
case 'secure-pdf-ingestion':
|
||||||
@ -60,7 +60,7 @@ export function useSavedAutomations() {
|
|||||||
return 'SettingsIcon'; // Default fallback
|
return 'SettingsIcon'; // Default fallback
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert suggested automation to saved automation format
|
// Convert suggested automation to saved automation format
|
||||||
const savedAutomation = {
|
const savedAutomation = {
|
||||||
name: suggestedAutomation.name,
|
name: suggestedAutomation.name,
|
||||||
@ -68,7 +68,7 @@ export function useSavedAutomations() {
|
|||||||
icon: getIconKey(suggestedAutomation.icon),
|
icon: getIconKey(suggestedAutomation.icon),
|
||||||
operations: suggestedAutomation.operations
|
operations: suggestedAutomation.operations
|
||||||
};
|
};
|
||||||
|
|
||||||
await automationStorage.saveAutomation(savedAutomation);
|
await automationStorage.saveAutomation(savedAutomation);
|
||||||
// Refresh the list after saving
|
// Refresh the list after saving
|
||||||
refreshAutomations();
|
refreshAutomations();
|
||||||
@ -91,4 +91,4 @@ export function useSavedAutomations() {
|
|||||||
deleteAutomation,
|
deleteAutomation,
|
||||||
copyFromSuggested
|
copyFromSuggested
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ import { SuggestedAutomation } from '../../../types/automation';
|
|||||||
|
|
||||||
// Create icon components
|
// Create icon components
|
||||||
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
|
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 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' });
|
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
import { CompressParameters, defaultParameters } from './useCompressParameters';
|
import { CompressParameters, defaultParameters } from './useCompressParameters';
|
||||||
|
|
||||||
|
@ -2,9 +2,8 @@ import { useCallback } from 'react';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
||||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
|
||||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
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';
|
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||||
|
|
||||||
// Static function that can be used by both the hook and automation executor
|
// Static function that can be used by both the hook and automation executor
|
||||||
|
@ -2,7 +2,6 @@ import {
|
|||||||
COLOR_TYPES,
|
COLOR_TYPES,
|
||||||
OUTPUT_OPTIONS,
|
OUTPUT_OPTIONS,
|
||||||
FIT_OPTIONS,
|
FIT_OPTIONS,
|
||||||
TO_FORMAT_OPTIONS,
|
|
||||||
CONVERSION_MATRIX,
|
CONVERSION_MATRIX,
|
||||||
type ColorType,
|
type ColorType,
|
||||||
type OutputOption,
|
type OutputOption,
|
||||||
@ -127,7 +126,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
endpointName: getEndpointName,
|
endpointName: getEndpointName,
|
||||||
validateFn: validateParameters,
|
validateFn: validateParameters,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
const baseHook = useBaseParameters(config);
|
const baseHook = useBaseParameters(config);
|
||||||
|
|
||||||
const getEndpoint = () => {
|
const getEndpoint = () => {
|
||||||
@ -166,7 +165,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
|
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
|
||||||
return prev; // No change needed
|
return prev; // No change needed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: false,
|
isSmartDetection: false,
|
||||||
@ -290,13 +289,13 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
// All files are images - use image-to-pdf conversion
|
// All files are images - use image-to-pdf conversion
|
||||||
baseHook.setParameters(prev => {
|
baseHook.setParameters(prev => {
|
||||||
// Only update if something actually changed
|
// Only update if something actually changed
|
||||||
if (prev.isSmartDetection === true &&
|
if (prev.isSmartDetection === true &&
|
||||||
prev.smartDetectionType === 'images' &&
|
prev.smartDetectionType === 'images' &&
|
||||||
prev.fromExtension === 'image' &&
|
prev.fromExtension === 'image' &&
|
||||||
prev.toExtension === 'pdf') {
|
prev.toExtension === 'pdf') {
|
||||||
return prev; // No change needed
|
return prev; // No change needed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: true,
|
isSmartDetection: true,
|
||||||
@ -309,13 +308,13 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
// All files are web files - use html-to-pdf conversion
|
// All files are web files - use html-to-pdf conversion
|
||||||
baseHook.setParameters(prev => {
|
baseHook.setParameters(prev => {
|
||||||
// Only update if something actually changed
|
// Only update if something actually changed
|
||||||
if (prev.isSmartDetection === true &&
|
if (prev.isSmartDetection === true &&
|
||||||
prev.smartDetectionType === 'web' &&
|
prev.smartDetectionType === 'web' &&
|
||||||
prev.fromExtension === 'html' &&
|
prev.fromExtension === 'html' &&
|
||||||
prev.toExtension === 'pdf') {
|
prev.toExtension === 'pdf') {
|
||||||
return prev; // No change needed
|
return prev; // No change needed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: true,
|
isSmartDetection: true,
|
||||||
@ -328,13 +327,13 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
// Mixed non-image types - use file-to-pdf conversion
|
// Mixed non-image types - use file-to-pdf conversion
|
||||||
baseHook.setParameters(prev => {
|
baseHook.setParameters(prev => {
|
||||||
// Only update if something actually changed
|
// Only update if something actually changed
|
||||||
if (prev.isSmartDetection === true &&
|
if (prev.isSmartDetection === true &&
|
||||||
prev.smartDetectionType === 'mixed' &&
|
prev.smartDetectionType === 'mixed' &&
|
||||||
prev.fromExtension === 'any' &&
|
prev.fromExtension === 'any' &&
|
||||||
prev.toExtension === 'pdf') {
|
prev.toExtension === 'pdf') {
|
||||||
return prev; // No change needed
|
return prev; // No change needed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: true,
|
isSmartDetection: true,
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect } from 'vitest';
|
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';
|
import { useConvertParameters } from './useConvertParameters';
|
||||||
|
|
||||||
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||||
@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
|||||||
|
|
||||||
const malformedFiles: Array<{name: string}> = [
|
const malformedFiles: Array<{name: string}> = [
|
||||||
{ name: 'valid.pdf' },
|
{ name: 'valid.pdf' },
|
||||||
// @ts-ignore - Testing runtime resilience
|
// @ts-expect-error - Testing runtime resilience
|
||||||
{ name: null },
|
{ name: null },
|
||||||
// @ts-ignore
|
// @ts-expect-error - Testing runtime resilience
|
||||||
{ name: undefined }
|
{ 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';
|
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
|
||||||
|
|
||||||
// Static function that can be used by both the hook and automation executor
|
// 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();
|
const formData = new FormData();
|
||||||
formData.append("fileInput", file);
|
formData.append("fileInput", file);
|
||||||
return formData;
|
return formData;
|
||||||
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
|||||||
import { RepairParameters, defaultParameters } from './useRepairParameters';
|
import { RepairParameters, defaultParameters } from './useRepairParameters';
|
||||||
|
|
||||||
// Static function that can be used by both the hook and automation executor
|
// 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();
|
const formData = new FormData();
|
||||||
formData.append("fileInput", file);
|
formData.append("fileInput", file);
|
||||||
return formData;
|
return formData;
|
||||||
|
@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
|
|||||||
import { BaseToolProps } from '../../../types/tool';
|
import { BaseToolProps } from '../../../types/tool';
|
||||||
import { ToolOperationHook } from './useToolOperation';
|
import { ToolOperationHook } from './useToolOperation';
|
||||||
import { BaseParametersHook } from './useBaseParameters';
|
import { BaseParametersHook } from './useBaseParameters';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
interface BaseToolReturn<TParams> {
|
interface BaseToolReturn<TParams> {
|
||||||
// File management
|
// File management
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
|
|
||||||
// Tool-specific hooks
|
// Tool-specific hooks
|
||||||
params: BaseParametersHook<TParams>;
|
params: BaseParametersHook<TParams>;
|
||||||
|
@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
|||||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||||
import { useToolResources } from './useToolResources';
|
import { useToolResources } from './useToolResources';
|
||||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import { FileId } from '../../../types/file';
|
|
||||||
import { FileRecord } from '../../../types/fileContext';
|
|
||||||
import { prepareFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
|
import { prepareFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
@ -105,7 +103,7 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
progress: ProcessingProgress | null;
|
progress: ProcessingProgress | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
|
||||||
resetResults: () => void;
|
resetResults: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
cancelOperation: () => void;
|
cancelOperation: () => void;
|
||||||
@ -131,7 +129,7 @@ export const useToolOperation = <TParams>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@ -141,13 +139,13 @@ export const useToolOperation = <TParams>(
|
|||||||
// Track last operation for undo functionality
|
// Track last operation for undo functionality
|
||||||
const lastOperationRef = useRef<{
|
const lastOperationRef = useRef<{
|
||||||
inputFiles: File[];
|
inputFiles: File[];
|
||||||
inputFileRecords: FileRecord[];
|
inputStirlingFileStubs: StirlingFileStub[];
|
||||||
outputFileIds: FileId[];
|
outputFileIds: FileId[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
const executeOperation = useCallback(async (
|
||||||
params: TParams,
|
params: TParams,
|
||||||
selectedFiles: File[]
|
selectedFiles: StirlingFile[]
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Validation
|
// Validation
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
@ -161,9 +159,6 @@ export const useToolOperation = <TParams>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup operation tracking
|
|
||||||
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
actions.setLoading(true);
|
actions.setLoading(true);
|
||||||
@ -188,8 +183,11 @@ export const useToolOperation = <TParams>(
|
|||||||
try {
|
try {
|
||||||
let processedFiles: File[];
|
let processedFiles: File[];
|
||||||
|
|
||||||
|
// Convert StirlingFile to regular File objects for API processing
|
||||||
|
const validRegularFiles = extractFiles(validFiles);
|
||||||
|
|
||||||
switch (config.toolType) {
|
switch (config.toolType) {
|
||||||
case ToolType.singleFile:
|
case ToolType.singleFile: {
|
||||||
// Individual file processing - separate API call per file
|
// Individual file processing - separate API call per file
|
||||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||||
endpoint: config.endpoint,
|
endpoint: config.endpoint,
|
||||||
@ -200,16 +198,18 @@ export const useToolOperation = <TParams>(
|
|||||||
processedFiles = await processFiles(
|
processedFiles = await processFiles(
|
||||||
params,
|
params,
|
||||||
filesWithHistory,
|
filesWithHistory,
|
||||||
|
validRegularFiles,
|
||||||
apiCallsConfig,
|
apiCallsConfig,
|
||||||
actions.setProgress,
|
actions.setProgress,
|
||||||
actions.setStatus
|
actions.setStatus
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case ToolType.multiFile:
|
case ToolType.multiFile: {
|
||||||
// Multi-file processing - single API call with all files
|
// Multi-file processing - single API call with all files
|
||||||
actions.setStatus('Processing 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 endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
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
|
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||||
if (config.responseHandler) {
|
if (config.responseHandler) {
|
||||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
// 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' ||
|
} 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
|
// 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' });
|
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||||
processedFiles = [singleFile];
|
processedFiles = [singleFile];
|
||||||
} else {
|
} else {
|
||||||
@ -234,10 +234,11 @@ export const useToolOperation = <TParams>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case ToolType.custom:
|
case ToolType.custom:
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
processedFiles = await config.customProcessor(params, filesWithHistory);
|
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,21 +261,17 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
// Replace input files with processed files (consumeFiles handles pinning)
|
// Replace input files with processed files (consumeFiles handles pinning)
|
||||||
const inputFileIds: FileId[] = [];
|
const inputFileIds: FileId[] = [];
|
||||||
const inputFileRecords: FileRecord[] = [];
|
const inputStirlingFileStubs: StirlingFileStub[] = [];
|
||||||
|
|
||||||
// Build parallel arrays of IDs and records for undo tracking
|
// Build parallel arrays of IDs and records for undo tracking
|
||||||
for (const file of validFiles) {
|
for (const file of validFiles) {
|
||||||
const fileId = findFileId(file);
|
const fileId = file.fileId;
|
||||||
if (fileId) {
|
const record = selectors.getStirlingFileStub(fileId);
|
||||||
const record = selectors.getFileRecord(fileId);
|
if (record) {
|
||||||
if (record) {
|
inputFileIds.push(fileId);
|
||||||
inputFileIds.push(fileId);
|
inputStirlingFileStubs.push(record);
|
||||||
inputFileRecords.push(record);
|
|
||||||
} else {
|
|
||||||
console.warn(`No file record found for file: ${file.name}`);
|
|
||||||
}
|
|
||||||
} else {
|
} 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)
|
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||||
lastOperationRef.current = {
|
lastOperationRef.current = {
|
||||||
inputFiles: validFiles, // Keep original File objects for undo
|
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
||||||
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||||
outputFileIds
|
outputFileIds
|
||||||
};
|
};
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||||
actions.setError(errorMessage);
|
actions.setError(errorMessage);
|
||||||
actions.setStatus('');
|
actions.setStatus('');
|
||||||
markOperationFailed(fileId, operationId, errorMessage);
|
|
||||||
} finally {
|
} finally {
|
||||||
actions.setLoading(false);
|
actions.setLoading(false);
|
||||||
actions.setProgress(null);
|
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(() => {
|
const cancelOperation = useCallback(() => {
|
||||||
cancelApiCalls();
|
cancelApiCalls();
|
||||||
@ -328,10 +323,10 @@ export const useToolOperation = <TParams>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
|
||||||
|
|
||||||
// Validate that we have data to undo
|
// 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'));
|
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -343,7 +338,8 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Undo the consume operation
|
// Undo the consume operation
|
||||||
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
||||||
|
|
||||||
|
|
||||||
// Clear results and operation tracking
|
// Clear results and operation tracking
|
||||||
resetResults();
|
resetResults();
|
||||||
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
|||||||
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
|
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
|
||||||
|
|
||||||
// Static function that can be used by both the hook and automation executor
|
// 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();
|
const formData = new FormData();
|
||||||
formData.append("fileInput", file);
|
formData.append("fileInput", file);
|
||||||
return formData;
|
return formData;
|
||||||
|
@ -70,7 +70,7 @@ export const useSplitOperation = () => {
|
|||||||
|
|
||||||
// Custom response handler that extracts ZIP files
|
// 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
|
// 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
|
// Split operations return ZIP files with multiple PDF pages
|
||||||
return await extractZipFiles(blob);
|
return await extractZipFiles(blob);
|
||||||
}, [extractZipFiles]);
|
}, [extractZipFiles]);
|
||||||
|
@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
|||||||
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
|
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
|
||||||
|
|
||||||
// Static function that can be used by both the hook and automation executor
|
// 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();
|
const formData = new FormData();
|
||||||
formData.append("fileInput", file);
|
formData.append("fileInput", file);
|
||||||
return formData;
|
return formData;
|
||||||
|
@ -184,11 +184,6 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
|
|||||||
// Force show after initialization
|
// Force show after initialization
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.CookieConsent.show();
|
window.CookieConsent.show();
|
||||||
|
|
||||||
// Debug: Check if modal elements exist
|
|
||||||
const ccMain = document.getElementById('cc-main');
|
|
||||||
const consentModal = document.querySelector('.cm-wrapper');
|
|
||||||
|
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -19,17 +19,17 @@ export function useEndpointEnabled(endpoint: string): {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEnabled: boolean = await response.json();
|
const isEnabled: boolean = await response.json();
|
||||||
setEnabled(isEnabled);
|
setEnabled(isEnabled);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -72,27 +72,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Use batch API for efficiency
|
// Use batch API for efficiency
|
||||||
const endpointsParam = endpoints.join(',');
|
const endpointsParam = endpoints.join(',');
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMap: Record<string, boolean> = await response.json();
|
const statusMap: Record<string, boolean> = await response.json();
|
||||||
setEndpointStatus(statusMap);
|
setEndpointStatus(statusMap);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error('Failed to check multiple endpoints:', err);
|
console.error('Failed to check multiple endpoints:', err);
|
||||||
|
|
||||||
// Fallback: assume all endpoints are disabled on error
|
// Fallback: assume all endpoints are disabled on error
|
||||||
const fallbackStatus = endpoints.reduce((acc, endpoint) => {
|
const fallbackStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
acc[endpoint] = false;
|
acc[endpoint] = false;
|
||||||
@ -105,7 +105,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const endpointsKey = endpoints.join(',');
|
|
||||||
fetchAllEndpointStatuses();
|
fetchAllEndpointStatuses();
|
||||||
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
||||||
|
|
||||||
@ -115,4 +114,4 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
error,
|
error,
|
||||||
refetch: fetchAllEndpointStatuses,
|
refetch: fetchAllEndpointStatuses,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ export function useEnhancedProcessedFiles(
|
|||||||
// Process files when activeFiles changes
|
// Process files when activeFiles changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
|
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
|
||||||
|
|
||||||
if (activeFiles.length === 0) {
|
if (activeFiles.length === 0) {
|
||||||
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
|
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
|
||||||
setProcessedFiles(new Map());
|
setProcessedFiles(new Map());
|
||||||
@ -60,15 +60,15 @@ export function useEnhancedProcessedFiles(
|
|||||||
|
|
||||||
const processFiles = async () => {
|
const processFiles = async () => {
|
||||||
const newProcessedFiles = new Map<File, ProcessedFile>();
|
const newProcessedFiles = new Map<File, ProcessedFile>();
|
||||||
|
|
||||||
for (const file of activeFiles) {
|
for (const file of activeFiles) {
|
||||||
// Generate hash for this file
|
// Generate hash for this file
|
||||||
const fileHash = await FileHasher.generateHybridHash(file);
|
const fileHash = await FileHasher.generateHybridHash(file);
|
||||||
fileHashMapRef.current.set(file, fileHash);
|
fileHashMapRef.current.set(file, fileHash);
|
||||||
|
|
||||||
// First, check if we have this exact File object cached
|
// First, check if we have this exact File object cached
|
||||||
let existing = processedFiles.get(file);
|
let existing = processedFiles.get(file);
|
||||||
|
|
||||||
// If not found by File object, try to find by hash in case File was recreated
|
// If not found by File object, try to find by hash in case File was recreated
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
for (const [cachedFile, processed] of processedFiles.entries()) {
|
for (const [cachedFile, processed] of processedFiles.entries()) {
|
||||||
@ -79,7 +79,7 @@ export function useEnhancedProcessedFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
newProcessedFiles.set(file, existing);
|
newProcessedFiles.set(file, existing);
|
||||||
continue;
|
continue;
|
||||||
@ -94,11 +94,11 @@ export function useEnhancedProcessedFiles(
|
|||||||
console.error(`Failed to start processing for ${file.name}:`, error);
|
console.error(`Failed to start processing for ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if the content actually changed
|
// Only update if the content actually changed
|
||||||
const hasChanged = newProcessedFiles.size !== processedFiles.size ||
|
const hasChanged = newProcessedFiles.size !== processedFiles.size ||
|
||||||
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
|
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
|
||||||
|
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
setProcessedFiles(newProcessedFiles);
|
setProcessedFiles(newProcessedFiles);
|
||||||
}
|
}
|
||||||
@ -112,20 +112,20 @@ export function useEnhancedProcessedFiles(
|
|||||||
const checkForCompletedFiles = async () => {
|
const checkForCompletedFiles = async () => {
|
||||||
let hasNewFiles = false;
|
let hasNewFiles = false;
|
||||||
const updatedFiles = new Map(processedFiles);
|
const updatedFiles = new Map(processedFiles);
|
||||||
|
|
||||||
// Generate file keys for all files first
|
// Generate file keys for all files first
|
||||||
const fileKeyPromises = activeFiles.map(async (file) => ({
|
const fileKeyPromises = activeFiles.map(async (file) => ({
|
||||||
file,
|
file,
|
||||||
key: await FileHasher.generateHybridHash(file)
|
key: await FileHasher.generateHybridHash(file)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fileKeyPairs = await Promise.all(fileKeyPromises);
|
const fileKeyPairs = await Promise.all(fileKeyPromises);
|
||||||
|
|
||||||
for (const { file, key } of fileKeyPairs) {
|
for (const { file, key } of fileKeyPairs) {
|
||||||
// Only check files that don't have processed results yet
|
// Only check files that don't have processed results yet
|
||||||
if (!updatedFiles.has(file)) {
|
if (!updatedFiles.has(file)) {
|
||||||
const processingState = processingStates.get(key);
|
const processingState = processingStates.get(key);
|
||||||
|
|
||||||
// Check for both processing and recently completed files
|
// Check for both processing and recently completed files
|
||||||
// This ensures we catch completed files before they're cleaned up
|
// This ensures we catch completed files before they're cleaned up
|
||||||
if (processingState?.status === 'processing' || processingState?.status === 'completed') {
|
if (processingState?.status === 'processing' || processingState?.status === 'completed') {
|
||||||
@ -135,13 +135,13 @@ export function useEnhancedProcessedFiles(
|
|||||||
updatedFiles.set(file, processed);
|
updatedFiles.set(file, processed);
|
||||||
hasNewFiles = true;
|
hasNewFiles = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore errors in completion check
|
// Ignore errors in completion check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNewFiles) {
|
if (hasNewFiles) {
|
||||||
setProcessedFiles(updatedFiles);
|
setProcessedFiles(updatedFiles);
|
||||||
}
|
}
|
||||||
@ -158,11 +158,11 @@ export function useEnhancedProcessedFiles(
|
|||||||
const currentFiles = new Set(activeFiles);
|
const currentFiles = new Set(activeFiles);
|
||||||
const previousFiles = Array.from(processedFiles.keys());
|
const previousFiles = Array.from(processedFiles.keys());
|
||||||
const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
|
const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
|
||||||
|
|
||||||
if (removedFiles.length > 0) {
|
if (removedFiles.length > 0) {
|
||||||
// Clean up processing service cache
|
// Clean up processing service cache
|
||||||
enhancedPDFProcessingService.cleanup(removedFiles);
|
enhancedPDFProcessingService.cleanup(removedFiles);
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setProcessedFiles(prev => {
|
setProcessedFiles(prev => {
|
||||||
const updated = new Map();
|
const updated = new Map();
|
||||||
@ -179,10 +179,10 @@ export function useEnhancedProcessedFiles(
|
|||||||
// Calculate derived state
|
// Calculate derived state
|
||||||
const isProcessing = processingStates.size > 0;
|
const isProcessing = processingStates.size > 0;
|
||||||
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
|
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
|
||||||
|
|
||||||
// Calculate overall progress
|
// Calculate overall progress
|
||||||
const processingProgress = calculateProcessingProgress(processingStates);
|
const processingProgress = calculateProcessingProgress(processingStates);
|
||||||
|
|
||||||
// Get cache stats and metrics
|
// Get cache stats and metrics
|
||||||
const cacheStats = enhancedPDFProcessingService.getCacheStats();
|
const cacheStats = enhancedPDFProcessingService.getCacheStats();
|
||||||
const metrics = enhancedPDFProcessingService.getMetrics();
|
const metrics = enhancedPDFProcessingService.getMetrics();
|
||||||
@ -192,7 +192,7 @@ export function useEnhancedProcessedFiles(
|
|||||||
cancelProcessing: (fileKey: string) => {
|
cancelProcessing: (fileKey: string) => {
|
||||||
enhancedPDFProcessingService.cancelProcessing(fileKey);
|
enhancedPDFProcessingService.cancelProcessing(fileKey);
|
||||||
},
|
},
|
||||||
|
|
||||||
retryProcessing: async (file: File) => {
|
retryProcessing: async (file: File) => {
|
||||||
try {
|
try {
|
||||||
await enhancedPDFProcessingService.processFile(file, config);
|
await enhancedPDFProcessingService.processFile(file, config);
|
||||||
@ -200,7 +200,7 @@ export function useEnhancedProcessedFiles(
|
|||||||
console.error(`Failed to retry processing for ${file.name}:`, error);
|
console.error(`Failed to retry processing for ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearCache: () => {
|
clearCache: () => {
|
||||||
enhancedPDFProcessingService.clearAll();
|
enhancedPDFProcessingService.clearAll();
|
||||||
}
|
}
|
||||||
@ -279,7 +279,7 @@ export function useEnhancedProcessedFile(
|
|||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
const result = useEnhancedProcessedFiles(file ? [file] : [], config);
|
const result = useEnhancedProcessedFiles(file ? [file] : [], config);
|
||||||
|
|
||||||
const processedFile = file ? result.processedFiles.get(file) || null : null;
|
const processedFile = file ? result.processedFiles.get(file) || null : null;
|
||||||
// Note: This is async but we can't await in hook return - consider refactoring if needed
|
// Note: This is async but we can't await in hook return - consider refactoring if needed
|
||||||
const fileKey = file ? '' : '';
|
const fileKey = file ? '' : '';
|
||||||
@ -309,4 +309,4 @@ export function useEnhancedProcessedFile(
|
|||||||
canRetry,
|
canRetry,
|
||||||
actions
|
actions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { FileId } from '../types/fileContext';
|
||||||
import { FileId } from '../types/file';
|
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { isFileObject } from '../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert a File object to { file: File; url: string } format
|
* 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(() => {
|
return useMemo(() => {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
// Validate that file is a proper File or Blob object
|
// Validate that file is a proper File, StirlingFile, or Blob object
|
||||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
if (!isFileObject(file) && !(file instanceof Blob)) {
|
||||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -2,21 +2,8 @@ import { useState, useEffect } from "react";
|
|||||||
import { FileMetadata } from "../types/file";
|
import { FileMetadata } from "../types/file";
|
||||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
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
|
* 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
|
// Try to load file from IndexedDB using new context
|
||||||
if (file.id && indexedDB) {
|
if (file.id && indexedDB) {
|
||||||
const loadedFile = await indexedDB.loadFile(file.id);
|
const loadedFile = await indexedDB.loadFile(file.id as FileId);
|
||||||
if (!loadedFile) {
|
if (!loadedFile) {
|
||||||
throw new Error('File not found in IndexedDB');
|
throw new Error('File not found in IndexedDB');
|
||||||
}
|
}
|
||||||
@ -66,11 +53,11 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
const thumbnail = await generateThumbnailForFile(fileObject);
|
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setThumb(thumbnail);
|
setThumb(thumbnail);
|
||||||
|
|
||||||
// Save thumbnail to IndexedDB for persistence
|
// Save thumbnail to IndexedDB for persistence
|
||||||
if (file.id && indexedDB && thumbnail) {
|
if (file.id && indexedDB && thumbnail) {
|
||||||
try {
|
try {
|
||||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export function usePDFProcessor() {
|
export function usePDFProcessor() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -75,7 +76,7 @@ export function usePDFProcessor() {
|
|||||||
// Create pages without thumbnails initially - load them lazily
|
// Create pages without thumbnails initially - load them lazily
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
originalPageNumber: i,
|
originalPageNumber: i,
|
||||||
thumbnail: null, // Will be loaded lazily
|
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