Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e36e722c | ||
|
|
1898df0df9 | ||
|
|
da359d329d | ||
|
|
bd13f6bf57 | ||
|
|
87c63efcec | ||
|
|
5caec41d96 | ||
|
|
d558bb5fac | ||
|
|
cd1fc682ab | ||
|
|
b9cf7e7820 | ||
|
|
94e8f603ff | ||
|
|
74609e54fe | ||
|
|
003285506f | ||
|
|
6d3b08d9b6 | ||
|
|
295e682e03 | ||
|
|
6a09ec6091 | ||
|
|
070f8b73e5 | ||
|
|
91631bb1de | ||
|
|
1a3e8e7ecf | ||
|
|
96aa43860b | ||
|
|
6dbaff5342 | ||
|
|
2f2f966ee9 | ||
|
|
3b28b398d9 | ||
|
|
eecc410b77 | ||
|
|
62c929b89b | ||
|
|
7c9769f5b9 | ||
|
|
cf771075f4 | ||
|
|
a7d5c80188 | ||
|
|
5b20f11e20 | ||
|
|
442b373ff4 | ||
|
|
e142af2863 | ||
|
|
581bafbd37 | ||
|
|
2cac8e8edf | ||
|
|
0002043266 | ||
|
|
00f0194b32 | ||
|
|
1c8b20b6e9 | ||
|
|
1eee5c9a11 | ||
|
|
68d59fd377 | ||
|
|
47ccb6a6ed | ||
|
|
3d26b054f1 | ||
|
|
9360a36c31 | ||
|
|
d64c56a897 | ||
|
|
ca423f9646 | ||
|
|
9b8091a630 | ||
|
|
4aac124e76 | ||
|
|
6d7f76353e | ||
|
|
16e028a8ef | ||
|
|
95b3e22229 | ||
|
|
4b70ef1298 | ||
|
|
1cc803545a | ||
|
|
fe9d2367d5 | ||
|
|
42d7664e25 | ||
|
|
bbd658d3b8 | ||
|
|
ed61c71db7 | ||
|
|
73deece29e | ||
|
|
55ebf9ebd0 | ||
|
|
e6f4cfb318 | ||
|
|
895bcebc7b | ||
|
|
61f5221c58 |
@@ -13,9 +13,10 @@
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(sed:*)"
|
||||
"Bash(sed:*)",
|
||||
"Bash(npm run typecheck:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"defaultMode": "acceptEdits"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{*.js,*.jsx,*.ts,*.tsx}]
|
||||
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
|
||||
indent_size = 2
|
||||
|
||||
[*.css]
|
||||
|
||||
@@ -147,6 +147,8 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
run: cd frontend && npm run build
|
||||
- name: Run frontend tests
|
||||
|
||||
Vendored
+1
@@ -19,5 +19,6 @@
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
||||
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
|
||||
]
|
||||
}
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# Adding New React Tools to Stirling PDF
|
||||
|
||||
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
|
||||
|
||||
## Overview
|
||||
|
||||
When adding tools, follow this systematic approach using the established patterns and architecture.
|
||||
|
||||
## 1. Create Tool Structure
|
||||
|
||||
Create these files in the correct directories:
|
||||
```
|
||||
frontend/src/hooks/tools/[toolName]/
|
||||
├── use[ToolName]Parameters.ts # Parameter definitions and validation
|
||||
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
|
||||
|
||||
frontend/src/components/tools/[toolName]/
|
||||
└── [ToolName]Settings.tsx # Settings UI component (if needed)
|
||||
|
||||
frontend/src/tools/
|
||||
└── [ToolName].tsx # Main tool component
|
||||
```
|
||||
|
||||
## 2. Implementation Pattern
|
||||
|
||||
Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools:
|
||||
|
||||
**Parameters Hook** (`use[ToolName]Parameters.ts`):
|
||||
```typescript
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface [ToolName]Parameters extends BaseParameters {
|
||||
// Define your tool-specific parameters here
|
||||
someOption: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: [ToolName]Parameters = {
|
||||
someOption: false,
|
||||
};
|
||||
|
||||
export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'your-endpoint-name',
|
||||
validateFn: (params) => true, // Add validation logic
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Operation Hook** (`use[ToolName]Operation.ts`):
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
|
||||
export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
// Add parameters to formData
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const [toolName]OperationConfig = {
|
||||
toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated)
|
||||
buildFormData: build[ToolName]FormData,
|
||||
operationType: '[toolName]',
|
||||
endpoint: '/api/v1/category/endpoint-name',
|
||||
filePrefix: 'processed_', // Will be overridden with translation
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const use[ToolName]Operation = () => {
|
||||
const { t } = useTranslation();
|
||||
return useToolOperation({
|
||||
...[toolName]OperationConfig,
|
||||
filePrefix: t('[toolName].filenamePrefix', 'processed') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed'))
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Main Component** (`[ToolName].tsx`):
|
||||
```typescript
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters";
|
||||
import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const [ToolName] = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
placeholder: t("[toolName].files.placeholder", "Select files to get started"),
|
||||
},
|
||||
steps: [
|
||||
// Add settings steps if needed
|
||||
],
|
||||
executeButton: {
|
||||
text: t("[toolName].submit", "Process"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("[toolName].results.title", "Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
[ToolName].tool = () => use[ToolName]Operation;
|
||||
export default [ToolName] as ToolComponent;
|
||||
```
|
||||
|
||||
**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support.
|
||||
|
||||
## 3. Register Tool in System
|
||||
Update these files to register your new tool:
|
||||
|
||||
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
|
||||
1. Add imports at the top:
|
||||
```typescript
|
||||
import [ToolName] from "../tools/[ToolName]";
|
||||
import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation";
|
||||
import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings";
|
||||
```
|
||||
|
||||
2. Add tool entry in the `allTools` object:
|
||||
```typescript
|
||||
[toolName]: {
|
||||
icon: <LocalIcon icon="your-icon-name" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.[toolName].title", "Tool Name"),
|
||||
component: [ToolName],
|
||||
description: t("home.[toolName].desc", "Tool description"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category
|
||||
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
|
||||
maxFiles: -1, // or specific number
|
||||
endpoints: ["endpoint-name"],
|
||||
operationConfig: [toolName]OperationConfig,
|
||||
settingsComponent: [ToolName]Settings, // if settings exist
|
||||
},
|
||||
```
|
||||
|
||||
## 4. Add Tooltips (Optional but Recommended)
|
||||
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
|
||||
|
||||
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const use[ToolName]Tips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("[toolName].tooltip.header.title", "Tool Overview")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("[toolName].tooltip.description.title", "What does this tool do?"),
|
||||
description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."),
|
||||
bullets: [
|
||||
t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"),
|
||||
t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2")
|
||||
]
|
||||
}
|
||||
// Add more tip sections as needed
|
||||
]
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Add tooltip to your main component:**
|
||||
```typescript
|
||||
import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
|
||||
|
||||
const [ToolName] = (props: BaseToolProps) => {
|
||||
const tips = use[ToolName]Tips();
|
||||
|
||||
// In your steps array:
|
||||
steps: [
|
||||
{
|
||||
title: t("[toolName].steps.settings", "Settings"),
|
||||
tooltip: tips, // Add this line
|
||||
content: <[ToolName]Settings ... />
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 5. Add Translations
|
||||
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
|
||||
|
||||
**File to update:** `frontend/public/locales/en-GB/translation.json`
|
||||
|
||||
**Required Translation Keys**:
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"[toolName]": {
|
||||
"title": "Tool Name",
|
||||
"desc": "Tool description"
|
||||
}
|
||||
},
|
||||
"[toolName]": {
|
||||
"title": "Tool Name",
|
||||
"submit": "Process",
|
||||
"filenamePrefix": "processed",
|
||||
"files": {
|
||||
"placeholder": "Select files to get started"
|
||||
},
|
||||
"steps": {
|
||||
"settings": "Settings"
|
||||
},
|
||||
"options": {
|
||||
"title": "Tool Options",
|
||||
"someOption": "Option Label",
|
||||
"someOption.desc": "Option description",
|
||||
"note": "General information about the tool."
|
||||
},
|
||||
"results": {
|
||||
"title": "Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Operation failed"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Tool Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "What does this tool do?",
|
||||
"text": "Simple explanation in everyday language",
|
||||
"bullet1": "Easy-to-understand benefit 1",
|
||||
"bullet2": "Easy-to-understand benefit 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Translation Notes:**
|
||||
- **Only update `en-GB/translation.json`** - other locale files are managed separately
|
||||
- Use descriptive keys that match your component's `t()` calls
|
||||
- Include tooltip translations if you created tooltip hooks
|
||||
- Add `options.*` keys if your tool has settings with descriptions
|
||||
|
||||
**Tooltip Writing Guidelines:**
|
||||
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
|
||||
- **Focus on benefits** - explain what the user gains, not how it works internally
|
||||
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
|
||||
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
|
||||
- **Keep descriptions concise** - 1-2 sentences maximum per section
|
||||
- **Use bullet points** for multiple benefits or features
|
||||
|
||||
## 6. Migration from Thymeleaf
|
||||
When migrating existing Thymeleaf templates:
|
||||
|
||||
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
|
||||
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
|
||||
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
|
||||
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
|
||||
|
||||
## 7. Testing Your Tool
|
||||
- Verify tool appears in UI with correct icon and description
|
||||
- Test with various file sizes and types
|
||||
- Confirm translations work
|
||||
- Check error handling
|
||||
- Test undo functionality
|
||||
- Verify results display correctly
|
||||
|
||||
## Tool Development Patterns
|
||||
|
||||
### Three Tool Patterns:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
@@ -208,6 +208,7 @@ return useToolOperation({
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
|
||||
+5
-1
@@ -24,4 +24,8 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
playwright-report
|
||||
test-results
|
||||
test-results
|
||||
|
||||
# auto-generated files
|
||||
/src/assets/material-symbols-icons.json
|
||||
/src/assets/material-symbols-icons.d.ts
|
||||
@@ -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)
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
|
||||
<title>Stirling PDF</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+973
-4720
File diff suppressed because it is too large
Load Diff
+15
-3
@@ -8,6 +8,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@mantine/core": "^8.0.1",
|
||||
"@mantine/dropzone": "^8.0.1",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
@@ -27,6 +28,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
@@ -35,10 +37,16 @@
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"predev": "npm run generate-icons",
|
||||
"dev": "npx tsc --noEmit && vite",
|
||||
"build": "npx tsc --noEmit && vite build && subfont dist --in-place --font-family='Material Symbols Rounded'",
|
||||
"prebuild": "npm run generate-icons",
|
||||
"lint": "npx eslint",
|
||||
"build": "npx tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate-licenses": "node scripts/generate-licenses.js",
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:coverage": "vitest --coverage",
|
||||
@@ -65,12 +73,16 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@iconify-json/material-symbols": "^1.2.33",
|
||||
"@iconify/utils": "^3.0.1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsdom": "^23.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
@@ -78,8 +90,8 @@
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"subfont": "^7.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="146" height="157" viewBox="0 0 146 157" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#E6E6E6" fill-opacity="0.4"/>
|
||||
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#E6E6E6" fill-opacity="0.7"/>
|
||||
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#E6E6E6" fill-opacity="0.4"/>
|
||||
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#E6E6E6" fill-opacity="0.7"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 253 B |
@@ -1,4 +1,4 @@
|
||||
<svg width="146" height="146" viewBox="0 0 146 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#ACACAC" fill-opacity="0.3"/>
|
||||
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#FC9999" fill-opacity="0.5"/>
|
||||
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#ACACAC" fill-opacity="0.3"/>
|
||||
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#FC9999" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 253 B |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
||||
/* Light theme variables */
|
||||
:root {
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
|
||||
--cc-btn-primary-bg: #007BFF;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007BFF;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007BFF;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007BFF;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747E;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme variables */
|
||||
.cc--darkmode{
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #2d2d2d;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #e5e5e5;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #e5e5e5;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #2d2d2d;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
|
||||
}
|
||||
|
||||
.cm__desc{
|
||||
max-width: 70rem !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
flex-direction: row-reverse !important;
|
||||
gap:10px !important;
|
||||
padding-top: 3.4rem !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1400px) {
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: normal !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility fixes */
|
||||
#cc-main .section__toggle {
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-circle {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Ensure toggles are visible in both themes */
|
||||
#cc-main .toggle__icon {
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
}
|
||||
|
||||
/* Ensure toggle text is visible */
|
||||
#cc-main .pm__section-title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .pm__section-desc {
|
||||
color: var(--cc-secondary-color) !important;
|
||||
}
|
||||
|
||||
/* Make sure the modal has proper contrast */
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -41,6 +41,9 @@
|
||||
"save": "Save",
|
||||
"saveToBrowser": "Save to Browser",
|
||||
"download": "Download",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"moreOptions": "More Options",
|
||||
"editYourNewFiles": "Edit your new file(s)",
|
||||
"close": "Close",
|
||||
"fileSelected": "Selected: {{filename}}",
|
||||
@@ -48,7 +51,11 @@
|
||||
"filesSelected": "{{count}} files selected",
|
||||
"files": {
|
||||
"title": "Files",
|
||||
"placeholder": "Select a PDF file in the main view to get started"
|
||||
"placeholder": "Select a PDF file in the main view to get started",
|
||||
"upload": "Upload",
|
||||
"uploadFiles": "Upload Files",
|
||||
"addFiles": "Add files",
|
||||
"selectFromWorkbench": "Select files from the workbench or "
|
||||
},
|
||||
"noFavourites": "No favourites added",
|
||||
"downloadComplete": "Download Complete",
|
||||
@@ -419,6 +426,10 @@
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"manageSignatures": {
|
||||
"title": "Sign with Certificate",
|
||||
"desc": "Add digital signatures to PDF documents using certificates"
|
||||
},
|
||||
"repair": {
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
@@ -604,6 +615,10 @@
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
"addFiles": "Add Files",
|
||||
"uploadFromComputer": "Upload from computer"
|
||||
},
|
||||
"viewPdf": {
|
||||
"tags": "view,read,annotate,text,image,highlight,edit",
|
||||
"title": "View/Edit PDF",
|
||||
@@ -1104,7 +1119,88 @@
|
||||
},
|
||||
"help": "Please read this documentation on how to use this for other languages and/or use not in docker",
|
||||
"credit": "This service uses qpdf and Tesseract for OCR.",
|
||||
"submit": "Process PDF with OCR"
|
||||
"submit": "Process PDF with OCR",
|
||||
"operation": {
|
||||
"submit": "Process OCR and Review"
|
||||
},
|
||||
"results": {
|
||||
"title": "OCR Results"
|
||||
},
|
||||
"languagePicker": {
|
||||
"additionalLanguages": "Looking for additional languages?",
|
||||
"viewSetupGuide": "View setup guide →"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"ocrMode": {
|
||||
"label": "OCR Mode",
|
||||
"auto": "Auto (skip text layers)",
|
||||
"force": "Force (re-OCR all, replace text)",
|
||||
"strict": "Strict (abort if text found)"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Languages",
|
||||
"placeholder": "Select languages"
|
||||
},
|
||||
"compatibilityMode": {
|
||||
"label": "Compatibility Mode"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"label": "Processing Options",
|
||||
"sidecar": "Create a text file",
|
||||
"deskew": "Deskew pages",
|
||||
"clean": "Clean input file",
|
||||
"cleanFinal": "Clean final output"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "OCR Settings Overview"
|
||||
},
|
||||
"mode": {
|
||||
"title": "OCR Mode",
|
||||
"text": "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight.",
|
||||
"bullet1": "Auto skips pages that already contain text layers.",
|
||||
"bullet2": "Force re-OCRs every page and replaces all the text.",
|
||||
"bullet3": "Strict halts if any selectable text is found."
|
||||
},
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
"text": "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection."
|
||||
},
|
||||
"output": {
|
||||
"title": "Output",
|
||||
"text": "Decide how you want the text output formatted:",
|
||||
"bullet1": "Searchable PDF embeds text behind the original image.",
|
||||
"bullet2": "HOCR XML returns a structured machine-readable file.",
|
||||
"bullet3": "Plain-text sidecar creates a separate .txt file with raw content."
|
||||
},
|
||||
"advanced": {
|
||||
"header": {
|
||||
"title": "Advanced OCR Processing"
|
||||
},
|
||||
"compatibility": {
|
||||
"title": "Compatibility Mode",
|
||||
"text": "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs."
|
||||
},
|
||||
"sidecar": {
|
||||
"title": "Create Text File",
|
||||
"text": "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing."
|
||||
},
|
||||
"deskew": {
|
||||
"title": "Deskew Pages",
|
||||
"text": "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned."
|
||||
},
|
||||
"clean": {
|
||||
"title": "Clean Input File",
|
||||
"text": "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing."
|
||||
},
|
||||
"cleanFinal": {
|
||||
"title": "Clean Final Output",
|
||||
"text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"extractImages": {
|
||||
"tags": "picture,photo,save,archive,zip,capture,grab",
|
||||
@@ -1213,7 +1309,48 @@
|
||||
"title": "Flatten",
|
||||
"header": "Flatten PDF",
|
||||
"flattenOnlyForms": "Flatten only forms",
|
||||
"submit": "Flatten"
|
||||
"submit": "Flatten",
|
||||
"filenamePrefix": "flattened",
|
||||
"files": {
|
||||
"placeholder": "Select a PDF file in the main view to get started"
|
||||
},
|
||||
"steps": {
|
||||
"settings": "Settings"
|
||||
},
|
||||
"options": {
|
||||
"stepTitle": "Flatten Options",
|
||||
"title": "Flatten Options",
|
||||
"flattenOnlyForms": "Flatten only forms",
|
||||
"flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact",
|
||||
"note": "Flattening removes interactive elements from the PDF, making them non-editable."
|
||||
},
|
||||
"results": {
|
||||
"title": "Flatten Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while flattening the PDF."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Flattening PDFs"
|
||||
},
|
||||
"description": {
|
||||
"title": "What does flattening do?",
|
||||
"text": "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere.",
|
||||
"bullet1": "Text boxes become regular text (can't be edited)",
|
||||
"bullet2": "Checkboxes and buttons become pictures",
|
||||
"bullet3": "Great for final versions you don't want changed",
|
||||
"bullet4": "Ensures consistent appearance across all devices"
|
||||
},
|
||||
"formsOnly": {
|
||||
"title": "What does 'Flatten only forms' mean?",
|
||||
"text": "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments.",
|
||||
"bullet1": "Forms become non-editable",
|
||||
"bullet2": "Links still work when clicked",
|
||||
"bullet3": "Comments and notes remain visible",
|
||||
"bullet4": "Bookmarks still help you navigate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore,correction,recover",
|
||||
@@ -1922,6 +2059,19 @@
|
||||
"currentPage": "Current Page",
|
||||
"totalPages": "Total Pages"
|
||||
},
|
||||
"rightRail": {
|
||||
"closeSelected": "Close Selected Files",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"selectByNumber": "Select by Page Numbers",
|
||||
"deleteSelected": "Delete Selected Pages",
|
||||
"closePdf": "Close PDF",
|
||||
"exportAll": "Export PDF",
|
||||
"downloadSelected": "Download Selected Files",
|
||||
"downloadAll": "Download All",
|
||||
"toggleTheme": "Toggle Theme",
|
||||
"language": "Language"
|
||||
},
|
||||
"toolPicker": {
|
||||
"searchPlaceholder": "Search tools...",
|
||||
"noToolsFound": "No tools found",
|
||||
@@ -1967,7 +2117,7 @@
|
||||
"loading": "Loading...",
|
||||
"or": "or",
|
||||
"dropFileHere": "Drop file here or click to upload",
|
||||
"dropFilesHere": "Drop files here or click to upload",
|
||||
"dropFilesHere": "Drop files here or click the upload button",
|
||||
"pdfFilesOnly": "PDF files only",
|
||||
"supportedFileTypes": "Supported file types",
|
||||
"upload": "Upload",
|
||||
@@ -2025,7 +2175,7 @@
|
||||
"selectedCount": "{{count}} selected",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"unsupported":"Unsupported"
|
||||
"unsupported": "Unsupported"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||
@@ -2197,66 +2347,228 @@
|
||||
}
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.",
|
||||
"invalidStep": "Invalid step",
|
||||
"files": {
|
||||
"placeholder": "Select files to process with this automation"
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.",
|
||||
"invalidStep": "Invalid step",
|
||||
"files": {
|
||||
"placeholder": "Select files to process with this automation"
|
||||
},
|
||||
"selection": {
|
||||
"title": "Automation Selection",
|
||||
"saved": {
|
||||
"title": "Saved"
|
||||
},
|
||||
"selection": {
|
||||
"title": "Automation Selection",
|
||||
"saved": {
|
||||
"title": "Saved"
|
||||
},
|
||||
"createNew": {
|
||||
"title": "Create New Automation"
|
||||
},
|
||||
"suggested": {
|
||||
"title": "Suggested"
|
||||
}
|
||||
"createNew": {
|
||||
"title": "Create New Automation"
|
||||
},
|
||||
"creation": {
|
||||
"createTitle": "Create Automation",
|
||||
"editTitle": "Edit Automation",
|
||||
"description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
|
||||
"name": {
|
||||
"placeholder": "Automation name"
|
||||
},
|
||||
"tools": {
|
||||
"selectTool": "Select a tool...",
|
||||
"selected": "Selected Tools",
|
||||
"remove": "Remove tool",
|
||||
"configure": "Configure tool",
|
||||
"notConfigured": "! Not Configured",
|
||||
"addTool": "Add Tool",
|
||||
"add": "Add a tool..."
|
||||
},
|
||||
"save": "Save Automation",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Go Back"
|
||||
}
|
||||
"suggested": {
|
||||
"title": "Suggested"
|
||||
}
|
||||
},
|
||||
"creation": {
|
||||
"createTitle": "Create Automation",
|
||||
"editTitle": "Edit Automation",
|
||||
"intro": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
|
||||
"name": {
|
||||
"label": "Automation Name",
|
||||
"placeholder": "My Automation"
|
||||
},
|
||||
"run": {
|
||||
"title": "Run Automation"
|
||||
"description": {
|
||||
"label": "Description (optional)",
|
||||
"placeholder": "Describe what this automation does..."
|
||||
},
|
||||
"sequence": {
|
||||
"unnamed": "Unnamed Automation",
|
||||
"steps": "{{count}} steps",
|
||||
"running": "Running Automation...",
|
||||
"run": "Run Automation",
|
||||
"finish": "Finish"
|
||||
"tools": {
|
||||
"selectTool": "Select a tool...",
|
||||
"selected": "Selected Tools",
|
||||
"remove": "Remove tool",
|
||||
"configure": "Configure tool",
|
||||
"notConfigured": "! Not Configured",
|
||||
"addTool": "Add Tool",
|
||||
"add": "Add a tool..."
|
||||
},
|
||||
"reviewTitle": "Automation Results",
|
||||
"config": {
|
||||
"loading": "Loading tool configuration...",
|
||||
"noSettings": "This tool does not have configurable settings.",
|
||||
"title": "Configure {{toolName}}",
|
||||
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
|
||||
"save": "Save Automation",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Configuration"
|
||||
"confirm": "Go Back"
|
||||
},
|
||||
"icon": {
|
||||
"label": "Icon"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"title": "Run Automation"
|
||||
},
|
||||
"sequence": {
|
||||
"unnamed": "Unnamed Automation",
|
||||
"steps": "{{count}} steps",
|
||||
"running": "Running Automation...",
|
||||
"run": "Run Automation",
|
||||
"finish": "Finish"
|
||||
},
|
||||
"reviewTitle": "Automation Results",
|
||||
"config": {
|
||||
"loading": "Loading tool configuration...",
|
||||
"noSettings": "This tool does not have configurable settings.",
|
||||
"title": "Configure {{toolName}}",
|
||||
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Configuration"
|
||||
},
|
||||
"copyToSaved": "Copy to Saved"
|
||||
},
|
||||
"automation": {
|
||||
"suggested": {
|
||||
"securePdfIngestion": "Secure PDF Ingestion",
|
||||
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitises documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimises file size.",
|
||||
"emailPreparation": "Email Preparation",
|
||||
"emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
|
||||
"secureWorkflow": "Security Workflow",
|
||||
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
|
||||
"processImages": "Process Images",
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
},
|
||||
"manageSignatures": {
|
||||
"title": "Sign with Certificate",
|
||||
"filenamePrefix": "signed",
|
||||
"files": {
|
||||
"placeholder": "Select PDF files to sign with certificates"
|
||||
},
|
||||
"fileStatus": {
|
||||
"stepTitle": "File Status"
|
||||
},
|
||||
"fileNavigation": "File {{current}} of {{total}}",
|
||||
"hasSignatures": "Contains {{count}} signature(s)",
|
||||
"noSignatures": "No signatures detected",
|
||||
"signed": "Signed",
|
||||
"certType": {
|
||||
"stepTitle": "Certificate Type",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Certificate Types"
|
||||
},
|
||||
"what": {
|
||||
"title": "What's a certificate?",
|
||||
"text": "It's a secure ID for your signature that proves you signed. Unless you're required to sign via certificate, we recommend using another secure method like Type, Draw, or Upload."
|
||||
},
|
||||
"which": {
|
||||
"title": "Which option should I use?",
|
||||
"text": "Choose the format that matches your certificate file:",
|
||||
"bullet1": "PKCS#12 (.p12 / .pfx) – one combined file (most common)",
|
||||
"bullet2": "PEM – separate private-key and certificate .pem files",
|
||||
"bullet3": "JKS – Java .jks keystore for dev / CI-CD workflows"
|
||||
},
|
||||
"convert": {
|
||||
"title": "Key not listed?",
|
||||
"text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
|
||||
}
|
||||
}
|
||||
},
|
||||
"certFiles": {
|
||||
"stepTitle": "Certificate Files"
|
||||
},
|
||||
"appearance": {
|
||||
"stepTitle": "Signature Appearance",
|
||||
"title": "Signature Appearance",
|
||||
"invisible": "Invisible",
|
||||
"visible": "Visible",
|
||||
"options": {
|
||||
"title": "Signature Details"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Signature Appearance"
|
||||
},
|
||||
"invisible": {
|
||||
"title": "Invisible Signatures",
|
||||
"text": "The signature is added to the PDF for security but won't be visible when viewing the document. Perfect for legal requirements without changing the document's appearance.",
|
||||
"bullet1": "Provides security without visual changes",
|
||||
"bullet2": "Meets legal requirements for digital signing",
|
||||
"bullet3": "Doesn't affect document layout or design"
|
||||
},
|
||||
"visible": {
|
||||
"title": "Visible Signatures",
|
||||
"text": "Shows a signature block on the PDF with your name, date, and optional details. Useful when you want readers to clearly see the document is signed.",
|
||||
"bullet1": "Shows signer name and date on the document",
|
||||
"bullet2": "Can include reason and location for signing",
|
||||
"bullet3": "Choose which page to place the signature",
|
||||
"bullet4": "Optional logo can be included"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"title": "Action",
|
||||
"validate": "Check for Signatures",
|
||||
"viewEdit": "View/Edit Signatures",
|
||||
"sign": "Add New Signature"
|
||||
},
|
||||
"validation": {
|
||||
"title": "Validation Options",
|
||||
"customCert": "Custom Certificate (Optional)",
|
||||
"customCert.desc": "Upload a custom certificate for validation"
|
||||
},
|
||||
"signing": {
|
||||
"title": "Certificate Settings",
|
||||
"certType": "Certificate Type",
|
||||
"choosePrivateKey": "Choose Private Key File",
|
||||
"chooseCertificate": "Choose Certificate File",
|
||||
"chooseP12File": "Choose PKCS12 File",
|
||||
"chooseJksFile": "Choose JKS File",
|
||||
"password": "Certificate Password",
|
||||
"passwordOptional": "Leave empty if no password",
|
||||
"showSignature": "Show visible signature on PDF",
|
||||
"reason": "Reason for Signing",
|
||||
"location": "Location",
|
||||
"name": "Signer Name",
|
||||
"pageNumber": "Page Number",
|
||||
"logoTitle": "Logo",
|
||||
"noLogo": "No Logo",
|
||||
"showLogo": "Show Logo"
|
||||
},
|
||||
"validate": {
|
||||
"submit": "Validate Signatures",
|
||||
"results": "Signature Validation Results"
|
||||
},
|
||||
"sign": {
|
||||
"submit": "Sign PDF",
|
||||
"results": "Signed PDF"
|
||||
},
|
||||
"results": {
|
||||
"title": "Signature Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred whilst processing signatures."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Managing Signatures"
|
||||
},
|
||||
"overview": {
|
||||
"title": "What can this tool do?",
|
||||
"text": "This tool lets you check if your PDFs are digitally signed and add new digital signatures. Digital signatures prove who created or approved a document and show if it has been changed since signing.",
|
||||
"bullet1": "Check existing signatures and their validity",
|
||||
"bullet2": "View detailed information about signers and certificates",
|
||||
"bullet3": "Add new digital signatures to secure your documents",
|
||||
"bullet4": "Multiple files supported with easy navigation"
|
||||
},
|
||||
"validation": {
|
||||
"title": "Checking Signatures",
|
||||
"text": "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing.",
|
||||
"bullet1": "Shows if signatures are valid or invalid",
|
||||
"bullet2": "Displays signer information and signing date",
|
||||
"bullet3": "Checks if the document was modified after signing",
|
||||
"bullet4": "Can use custom certificates for verification"
|
||||
},
|
||||
"signing": {
|
||||
"title": "Adding Signatures",
|
||||
"text": "To sign a PDF, you need a digital certificate (like PEM, PKCS12, or JKS). You can choose to make the signature visible on the document or keep it invisible for security only.",
|
||||
"bullet1": "Supports PEM, PKCS12, and JKS certificate formats",
|
||||
"bullet2": "Option to show or hide signature on the PDF",
|
||||
"bullet3": "Add reason, location, and signer name",
|
||||
"bullet4": "Choose which page to place visible signatures"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"bored": "Bored Waiting?",
|
||||
"alphabet": "Alphabet",
|
||||
"downloadPdf": "Download PDF",
|
||||
|
||||
"text": "Text",
|
||||
"font": "Font",
|
||||
"selectFillter": "-- Select --",
|
||||
@@ -607,6 +608,10 @@
|
||||
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
"addFiles": "Add Files",
|
||||
"uploadFromComputer": "Upload from computer"
|
||||
},
|
||||
"viewPdf": {
|
||||
"tags": "view,read,annotate,text,image,highlight,edit",
|
||||
"title": "View/Edit PDF",
|
||||
@@ -2068,6 +2073,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"rightRail": {
|
||||
"closePdf": "Close PDF",
|
||||
"closeSelected": "Close Selected Files",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"selectByNumber": "Select by Page Numbers",
|
||||
"deleteSelected": "Delete Selected Pages",
|
||||
"toggleTheme": "Toggle Theme",
|
||||
"exportAll": "Export PDF",
|
||||
"downloadSelected": "Download Selected Files",
|
||||
"downloadAll": "Download All"
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "Remove Password",
|
||||
"desc": "Remove password protection from your PDF document.",
|
||||
@@ -2089,5 +2106,20 @@
|
||||
"results": {
|
||||
"title": "Decrypted PDFs"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
"suggested": {
|
||||
"securePdfIngestion": "Secure PDF Ingestion",
|
||||
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.",
|
||||
"emailPreparation": "Email Preparation",
|
||||
"emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
|
||||
"secureWorkflow": "Security Workflow",
|
||||
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default.",
|
||||
"processImages": "Process Images",
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
},
|
||||
"automate": {
|
||||
"copyToSaved": "Copy to Saved"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { icons } = require('@iconify-json/material-symbols');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Check for verbose flag
|
||||
const isVerbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
||||
|
||||
// Logging functions
|
||||
const info = (message) => console.log(message);
|
||||
const debug = (message) => {
|
||||
if (isVerbose) {
|
||||
console.log(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to scan codebase for LocalIcon usage
|
||||
function scanForUsedIcons() {
|
||||
const usedIcons = new Set();
|
||||
const srcDir = path.join(__dirname, '..', 'src');
|
||||
|
||||
info('🔍 Scanning codebase for LocalIcon usage...');
|
||||
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.error('❌ Source directory not found:', srcDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Recursively scan all .tsx and .ts files
|
||||
function scanDirectory(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
scanDirectory(filePath);
|
||||
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
||||
if (localIconMatches) {
|
||||
localIconMatches.forEach(match => {
|
||||
const iconMatch = match.match(/icon="([^"]+)"/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
||||
if (spanMatches) {
|
||||
spanMatches.forEach(match => {
|
||||
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
||||
if (iconMatch && iconMatch[1].trim()) {
|
||||
const iconName = iconMatch[1].trim();
|
||||
usedIcons.add(iconName);
|
||||
debug(` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
||||
if (iconMatches) {
|
||||
iconMatches.forEach(match => {
|
||||
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
debug(` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scanDirectory(srcDir);
|
||||
|
||||
const iconArray = Array.from(usedIcons).sort();
|
||||
info(`📋 Found ${iconArray.length} unique icons across codebase`);
|
||||
|
||||
return iconArray;
|
||||
}
|
||||
|
||||
// Main async function
|
||||
async function main() {
|
||||
// Auto-detect used icons
|
||||
const usedIcons = scanForUsedIcons();
|
||||
|
||||
// Check if we need to regenerate (compare with existing)
|
||||
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
|
||||
let needsRegeneration = true;
|
||||
|
||||
if (fs.existsSync(outputPath)) {
|
||||
try {
|
||||
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
const existingIcons = Object.keys(existingSet.icons || {}).sort();
|
||||
const currentIcons = [...usedIcons].sort();
|
||||
|
||||
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
|
||||
needsRegeneration = false;
|
||||
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse existing file, regenerate
|
||||
needsRegeneration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsRegeneration) {
|
||||
info('🎉 No regeneration needed!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
|
||||
|
||||
// Dynamic import of ES module
|
||||
const { getIcons } = await import('@iconify/utils');
|
||||
|
||||
// Extract only our used icons from the full set
|
||||
const extractedIcons = getIcons(icons, usedIcons);
|
||||
|
||||
if (!extractedIcons) {
|
||||
console.error('❌ Failed to extract icons');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for missing icons
|
||||
const extractedIconNames = Object.keys(extractedIcons.icons || {});
|
||||
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
|
||||
|
||||
if (missingIcons.length > 0) {
|
||||
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
|
||||
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
const outputDir = path.join(__dirname, '..', 'src', 'assets');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the extracted icon set to a file (outputPath already defined above)
|
||||
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
|
||||
|
||||
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
|
||||
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
|
||||
info(`💾 Saved to: ${outputPath}`);
|
||||
|
||||
// Generate TypeScript types
|
||||
const typesContent = `// Auto-generated icon types
|
||||
// This file is automatically generated by scripts/generate-icons.js
|
||||
// Do not edit manually - changes will be overwritten
|
||||
|
||||
export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')};
|
||||
|
||||
export interface IconSet {
|
||||
prefix: string;
|
||||
icons: Record<string, any>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Re-export the icon set as the default export with proper typing
|
||||
declare const iconSet: IconSet;
|
||||
export default iconSet;
|
||||
`;
|
||||
|
||||
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
|
||||
fs.writeFileSync(typesPath, typesContent);
|
||||
|
||||
info(`📝 Generated types: ${typesPath}`);
|
||||
info(`🎉 Icon extraction complete!`);
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
main().catch(error => {
|
||||
console.error('❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -24,17 +24,17 @@ try {
|
||||
// Install license-checker if not present
|
||||
try {
|
||||
require.resolve('license-checker');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.log('📦 Installing license-checker...');
|
||||
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// Generate license report using license-checker (more reliable)
|
||||
const licenseReport = execSync('npx license-checker --production --json', {
|
||||
const licenseReport = execSync('npx license-checker --production --json', {
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON)
|
||||
});
|
||||
|
||||
|
||||
let licenseData;
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
@@ -43,16 +43,16 @@ try {
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
if (!licenseData || typeof licenseData !== 'object') {
|
||||
console.error('❌ Invalid license data structure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
// Convert license-checker format to array
|
||||
const licenseArray = Object.entries(licenseData).map(([key, value]) => {
|
||||
let name, version;
|
||||
|
||||
|
||||
// Handle scoped packages like @mantine/core@1.0.0
|
||||
if (key.startsWith('@')) {
|
||||
const parts = key.split('@');
|
||||
@@ -64,30 +64,30 @@ try {
|
||||
name = key.substring(0, lastAtIndex);
|
||||
version = key.substring(lastAtIndex + 1);
|
||||
}
|
||||
|
||||
|
||||
// Normalize license types for edge cases
|
||||
let licenseType = value.licenses;
|
||||
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
// Handle empty string licenses
|
||||
if (licenseType === '') {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
// Handle array licenses (rare but possible)
|
||||
if (Array.isArray(licenseType)) {
|
||||
licenseType = licenseType.join(' AND ');
|
||||
}
|
||||
|
||||
|
||||
// Handle object licenses (fallback)
|
||||
if (typeof licenseType === 'object' && licenseType !== null) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
name: name,
|
||||
version: version || value.version || 'unknown',
|
||||
@@ -97,13 +97,13 @@ try {
|
||||
link: value.licenseUrl
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Transform to match Java backend format
|
||||
const transformedData = {
|
||||
dependencies: licenseArray.map(dep => {
|
||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
||||
|
||||
|
||||
return {
|
||||
moduleName: dep.name,
|
||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
@@ -113,29 +113,29 @@ try {
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
// Log summary of license types found
|
||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
||||
acc[license] = (acc[license] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
console.log('📊 License types found:');
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
console.log(` ${license}: ${count} packages`);
|
||||
});
|
||||
|
||||
|
||||
// Log any complex or unusual license formats for debugging
|
||||
const complexLicenses = licenseArray.filter(dep =>
|
||||
const complexLicenses = licenseArray.filter(dep =>
|
||||
dep.licenseType && (
|
||||
dep.licenseType.includes('AND') ||
|
||||
dep.licenseType.includes('OR') ||
|
||||
dep.licenseType.includes('AND') ||
|
||||
dep.licenseType.includes('OR') ||
|
||||
dep.licenseType === 'Unknown' ||
|
||||
dep.licenseType.includes('SEE LICENSE')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (complexLicenses.length > 0) {
|
||||
console.log('\n🔍 Complex/Edge case licenses detected:');
|
||||
complexLicenses.forEach(dep => {
|
||||
@@ -150,7 +150,7 @@ try {
|
||||
problematicLicenses.forEach(warning => {
|
||||
console.log(` ${warning.message}`);
|
||||
});
|
||||
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
|
||||
fs.writeFileSync(warningsFile, JSON.stringify({
|
||||
@@ -164,11 +164,11 @@ try {
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
|
||||
|
||||
console.log(`✅ License report generated successfully!`);
|
||||
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
|
||||
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating license report:', error.message);
|
||||
process.exit(1);
|
||||
@@ -179,9 +179,10 @@ try {
|
||||
*/
|
||||
function getLicenseUrl(licenseType) {
|
||||
if (!licenseType || licenseType === 'Unknown') return '';
|
||||
|
||||
|
||||
const licenseUrls = {
|
||||
'MIT': 'https://opensource.org/licenses/MIT',
|
||||
'MIT*': 'https://opensource.org/licenses/MIT',
|
||||
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause',
|
||||
@@ -206,12 +207,12 @@ function getLicenseUrl(licenseType) {
|
||||
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||
'UNLICENSED': ''
|
||||
};
|
||||
|
||||
|
||||
// Try exact match first
|
||||
if (licenseUrls[licenseType]) {
|
||||
return licenseUrls[licenseType];
|
||||
}
|
||||
|
||||
|
||||
// Try case-insensitive match
|
||||
const lowerType = licenseType.toLowerCase();
|
||||
for (const [key, url] of Object.entries(licenseUrls)) {
|
||||
@@ -219,16 +220,16 @@ function getLicenseUrl(licenseType) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/);
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
|
||||
if (match && licenseUrls[match[1]]) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For non-standard licenses, return empty string (will use package link if available)
|
||||
return '';
|
||||
}
|
||||
@@ -238,7 +239,7 @@ function getLicenseUrl(licenseType) {
|
||||
*/
|
||||
function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
const warnings = [];
|
||||
|
||||
|
||||
// Define problematic license patterns
|
||||
const problematicLicenses = {
|
||||
// Copyleft licenses
|
||||
@@ -248,7 +249,7 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications',
|
||||
'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use',
|
||||
'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use',
|
||||
|
||||
|
||||
// Other potentially problematic licenses
|
||||
'WTFPL': 'Potentially problematic license - legal uncertainty',
|
||||
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
|
||||
@@ -267,47 +268,47 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
'UNLICENSED': 'No license specified - usage rights unclear',
|
||||
'Unknown': 'License not detected - manual review required'
|
||||
};
|
||||
|
||||
|
||||
// Known good licenses (no warnings needed)
|
||||
const goodLicenses = new Set([
|
||||
'MIT', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
|
||||
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE'
|
||||
]);
|
||||
|
||||
|
||||
// Helper function to normalize license names for comparison
|
||||
function normalizeLicense(license) {
|
||||
return license
|
||||
.replace(/-or-later$/, '') // Remove -or-later suffix
|
||||
.replace(/\+$/, '') // Remove + suffix
|
||||
.replace(/\+$/, '') // Remove + suffix
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
// Check each license type
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
// Skip known good licenses
|
||||
if (goodLicenses.has(license)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if this license only affects our own packages
|
||||
const affectedPackages = licenseArray.filter(dep => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
|
||||
return depLicense === license;
|
||||
});
|
||||
|
||||
const isOnlyOurPackages = affectedPackages.every(dep =>
|
||||
dep.name === 'frontend' ||
|
||||
|
||||
const isOnlyOurPackages = affectedPackages.every(dep =>
|
||||
dep.name === 'frontend' ||
|
||||
dep.name.toLowerCase().includes('stirling-pdf') ||
|
||||
dep.name.toLowerCase().includes('stirling_pdf') ||
|
||||
dep.name.toLowerCase().includes('stirlingpdf')
|
||||
);
|
||||
|
||||
|
||||
if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) {
|
||||
return; // Skip warnings for our own Stirling-PDF packages
|
||||
}
|
||||
|
||||
|
||||
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (license.includes('AND') || license.includes('OR')) {
|
||||
// For OR licenses, check if there's at least one acceptable license option
|
||||
@@ -317,23 +318,23 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
.replace(/[()]/g, '') // Remove parentheses
|
||||
.split(' OR ')
|
||||
.map(component => component.trim());
|
||||
|
||||
|
||||
// Check if any component is in the goodLicenses set (with normalization)
|
||||
const hasGoodLicense = orComponents.some(component => {
|
||||
const normalized = normalizeLicense(component);
|
||||
return goodLicenses.has(component) || goodLicenses.has(normalized);
|
||||
});
|
||||
|
||||
|
||||
if (hasGoodLicense) {
|
||||
return; // Skip warning - can use the good license option
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
|
||||
license.includes(problematic)
|
||||
);
|
||||
|
||||
|
||||
if (hasProblematicComponent) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter(dep => {
|
||||
@@ -345,12 +346,12 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
|
||||
const licenseType = license.includes('AND') ? 'AND' : 'OR';
|
||||
const reason = licenseType === 'AND'
|
||||
const reason = licenseType === 'AND'
|
||||
? 'Compound license with AND requirement - all components must be compatible'
|
||||
: 'Compound license with potentially problematic components and no good fallback options';
|
||||
|
||||
|
||||
warnings.push({
|
||||
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
|
||||
licenseType: license,
|
||||
@@ -362,7 +363,7 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check for exact matches with problematic licenses
|
||||
if (problematicLicenses[license]) {
|
||||
const affectedPackages = licenseArray
|
||||
@@ -375,10 +376,10 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
|
||||
const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : '');
|
||||
const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses';
|
||||
|
||||
|
||||
warnings.push({
|
||||
message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
||||
licenseType: license,
|
||||
@@ -399,7 +400,7 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
|
||||
warnings.push({
|
||||
message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`,
|
||||
licenseType: license,
|
||||
@@ -410,6 +411,6 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ import HomePage from "./pages/HomePage";
|
||||
|
||||
// Import global styles
|
||||
import "./styles/tailwind.css";
|
||||
import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
|
||||
// Loading component for i18next suspense
|
||||
const LoadingFallback = () => (
|
||||
@@ -38,7 +43,9 @@ export default function App() {
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<HomePage />
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
||||
"moduleUrl": "https://github.com/atlassian/pragmatic-drag-and-drop",
|
||||
"moduleVersion": "1.7.4",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "@babel/code-frame",
|
||||
"moduleUrl": "https://github.com/babel/babel",
|
||||
@@ -59,7 +66,7 @@
|
||||
{
|
||||
"moduleName": "@babel/parser",
|
||||
"moduleUrl": "https://github.com/babel/babel",
|
||||
"moduleVersion": "7.27.3",
|
||||
"moduleVersion": "7.28.3",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
@@ -87,7 +94,7 @@
|
||||
{
|
||||
"moduleName": "@babel/types",
|
||||
"moduleUrl": "https://github.com/babel/babel",
|
||||
"moduleVersion": "7.27.3",
|
||||
"moduleVersion": "7.28.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
@@ -217,6 +224,20 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "@iconify/react",
|
||||
"moduleUrl": "https://github.com/iconify/iconify",
|
||||
"moduleVersion": "6.0.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "@iconify/types",
|
||||
"moduleUrl": "https://github.com/iconify/iconify",
|
||||
"moduleVersion": "2.0.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "@isaacs/fs-minipass",
|
||||
"moduleUrl": "https://github.com/npm/fs-minipass",
|
||||
@@ -399,6 +420,20 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tanstack/react-virtual",
|
||||
"moduleUrl": "https://github.com/TanStack/virtual",
|
||||
"moduleVersion": "3.13.12",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tanstack/virtual-core",
|
||||
"moduleUrl": "https://github.com/TanStack/virtual",
|
||||
"moduleVersion": "3.13.12",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "@testing-library/dom",
|
||||
"moduleUrl": "https://github.com/testing-library/dom-testing-library",
|
||||
@@ -567,6 +602,13 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "bind-event-listener",
|
||||
"moduleUrl": "https://github.com/alexreardon/bind-event-listener",
|
||||
"moduleVersion": "3.0.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "brace-expansion",
|
||||
"moduleUrl": "https://github.com/juliangruber/brace-expansion",
|
||||
@@ -1246,13 +1288,6 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "material-symbols",
|
||||
"moduleUrl": "https://github.com/marella/material-symbols",
|
||||
"moduleVersion": "0.33.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "math-intrinsics",
|
||||
"moduleUrl": "https://github.com/es-shims/math-intrinsics",
|
||||
@@ -1494,7 +1529,7 @@
|
||||
{
|
||||
"moduleName": "postcss",
|
||||
"moduleUrl": "https://github.com/postcss/postcss",
|
||||
"moduleVersion": "8.5.3",
|
||||
"moduleVersion": "8.5.6",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
@@ -1526,6 +1561,13 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "raf-schd",
|
||||
"moduleUrl": "https://github.com/alexreardon/raf-schd",
|
||||
"moduleVersion": "4.0.3",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-dom",
|
||||
"moduleUrl": "https://github.com/facebook/react",
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import { Command, CommandSequence } from '../hooks/useUndoRedo';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
|
||||
// Base class for page operations
|
||||
abstract class PageCommand implements Command {
|
||||
protected pdfDocument: PDFDocument;
|
||||
protected setPdfDocument: (doc: PDFDocument) => void;
|
||||
protected previousState: PDFDocument;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void
|
||||
) {
|
||||
this.pdfDocument = pdfDocument;
|
||||
this.setPdfDocument = setPdfDocument;
|
||||
this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone
|
||||
}
|
||||
|
||||
abstract execute(): void;
|
||||
abstract description: string;
|
||||
|
||||
undo(): void {
|
||||
this.setPdfDocument(this.previousState);
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate pages command
|
||||
export class RotatePagesCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private rotation: number;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[],
|
||||
rotation: number
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.rotation = rotation;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const updatedPages = this.pdfDocument.pages.map(page => {
|
||||
if (this.pageIds.includes(page.id)) {
|
||||
return { ...page, rotation: page.rotation + this.rotation };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const direction = this.rotation > 0 ? 'right' : 'left';
|
||||
return `Rotate ${this.pageIds.length} page(s) ${direction}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pages command
|
||||
export class DeletePagesCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private deletedPages: PDFPage[];
|
||||
private deletedPositions: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[]
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.deletedPages = [];
|
||||
this.deletedPositions = new Map();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store deleted pages and their positions for undo
|
||||
this.deletedPages = this.pdfDocument.pages.filter(page =>
|
||||
this.pageIds.includes(page.id)
|
||||
);
|
||||
|
||||
this.deletedPages.forEach(page => {
|
||||
const index = this.pdfDocument.pages.findIndex(p => p.id === page.id);
|
||||
this.deletedPositions.set(page.id, index);
|
||||
});
|
||||
|
||||
const updatedPages = this.pdfDocument.pages
|
||||
.filter(page => !this.pageIds.includes(page.id))
|
||||
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Simply restore to the previous state (before deletion)
|
||||
this.setPdfDocument(this.previousState);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Delete ${this.pageIds.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Move pages command
|
||||
export class MovePagesCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private targetIndex: number;
|
||||
private originalIndices: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[],
|
||||
targetIndex: number
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.targetIndex = targetIndex;
|
||||
this.originalIndices = new Map();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original positions
|
||||
this.pageIds.forEach(pageId => {
|
||||
const index = this.pdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
this.originalIndices.set(pageId, index);
|
||||
});
|
||||
|
||||
let newPages = [...this.pdfDocument.pages];
|
||||
const pagesToMove = this.pageIds
|
||||
.map(id => this.pdfDocument.pages.find(p => p.id === id))
|
||||
.filter((page): page is PDFPage => page !== undefined);
|
||||
|
||||
// Remove pages to move
|
||||
newPages = newPages.filter(page => !this.pageIds.includes(page.id));
|
||||
|
||||
// Insert pages at target position
|
||||
newPages.splice(this.targetIndex, 0, ...pagesToMove);
|
||||
|
||||
// Update page numbers
|
||||
newPages = newPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Move ${this.pageIds.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder single page command (for drag-and-drop)
|
||||
export class ReorderPageCommand extends PageCommand {
|
||||
private pageId: string;
|
||||
private targetIndex: number;
|
||||
private originalIndex: number;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageId: string,
|
||||
targetIndex: number
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageId = pageId;
|
||||
this.targetIndex = targetIndex;
|
||||
this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const newPages = [...this.pdfDocument.pages];
|
||||
const [movedPage] = newPages.splice(this.originalIndex, 1);
|
||||
newPages.splice(this.targetIndex, 0, movedPage);
|
||||
|
||||
// Update page numbers
|
||||
const updatedPages = newPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle split markers command
|
||||
export class ToggleSplitCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private previousSplitStates: Map<string, boolean>;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[]
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.previousSplitStates = new Map();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store previous split states
|
||||
this.pageIds.forEach(pageId => {
|
||||
const page = this.pdfDocument.pages.find(p => p.id === pageId);
|
||||
if (page) {
|
||||
this.previousSplitStates.set(pageId, !!page.splitBefore);
|
||||
}
|
||||
});
|
||||
|
||||
const updatedPages = this.pdfDocument.pages.map(page => {
|
||||
if (this.pageIds.includes(page.id)) {
|
||||
return { ...page, splitBefore: !page.splitBefore };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const updatedPages = this.pdfDocument.pages.map(page => {
|
||||
if (this.pageIds.includes(page.id)) {
|
||||
const previousState = this.previousSplitStates.get(page.id);
|
||||
return { ...page, splitBefore: previousState };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Toggle split markers for ${this.pageIds.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add pages command (for inserting new files)
|
||||
export class AddPagesCommand extends PageCommand {
|
||||
private newPages: PDFPage[];
|
||||
private insertIndex: number;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
newPages: PDFPage[],
|
||||
insertIndex: number = -1 // -1 means append to end
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.newPages = newPages;
|
||||
this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const newPagesArray = [...this.pdfDocument.pages];
|
||||
newPagesArray.splice(this.insertIndex, 0, ...this.newPages);
|
||||
|
||||
// Update page numbers for all pages
|
||||
const updatedPages = newPagesArray.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const updatedPages = this.pdfDocument.pages
|
||||
.filter(page => !this.newPages.some(newPage => newPage.id === page.id))
|
||||
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Add ${this.newPages.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Command sequence for bulk operations
|
||||
export class PageCommandSequence implements CommandSequence {
|
||||
commands: Command[];
|
||||
description: string;
|
||||
|
||||
constructor(commands: Command[], description?: string) {
|
||||
this.commands = commands;
|
||||
this.description = description || `Execute ${commands.length} operations`;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.commands.forEach(command => command.execute());
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Undo in reverse order
|
||||
[...this.commands].reverse().forEach(command => command.undo());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Dropzone } from '@mantine/dropzone';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { useFileManager } from '../hooks/useFileManager';
|
||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||
import { createFileId } from '../types/fileContext';
|
||||
import { Tool } from '../types/tool';
|
||||
import MobileLayout from './fileManager/MobileLayout';
|
||||
import DesktopLayout from './fileManager/DesktopLayout';
|
||||
@@ -21,13 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||
|
||||
// Wrapper for storeFile that generates UUID
|
||||
const storeFileWithId = useCallback(async (file: File) => {
|
||||
const fileId = createFileId(); // Generate UUID for storage
|
||||
return await storeFile(file, fileId);
|
||||
}, [storeFile]);
|
||||
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/* =========================
|
||||
FileEditor Card UI Styles
|
||||
========================= */
|
||||
|
||||
.card {
|
||||
background: var(--file-card-bg);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card[data-selected="true"] {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* While dragging */
|
||||
.card.dragging,
|
||||
.card:global(.dragging) {
|
||||
outline: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* -------- Header -------- */
|
||||
.header {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
background: var(--bg-toolbar);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.headerResting {
|
||||
background: #3B4B6E; /* dark blue for unselected in light mode */
|
||||
color: #FFFFFF;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.headerSelected {
|
||||
background: var(--header-selected-bg);
|
||||
color: var(--header-selected-fg);
|
||||
border-bottom: 1px solid var(--header-selected-bg);
|
||||
}
|
||||
|
||||
/* Selected border color in light mode */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: var(--card-selected-border);
|
||||
}
|
||||
|
||||
/* Reserve space for checkbox instead of logo */
|
||||
.logoMark {
|
||||
margin-left: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.headerIndex {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
.menuDropdown {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
/* -------- Title / Meta -------- */
|
||||
.title {
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* -------- Preview area -------- */
|
||||
.previewBox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--file-card-bg);
|
||||
}
|
||||
|
||||
.previewPaper {
|
||||
width: 100%;
|
||||
height: calc(100% - 6px);
|
||||
min-height: 9rem;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--file-card-bg);
|
||||
}
|
||||
|
||||
/* Thumbnail fallback */
|
||||
.previewPaper[data-thumb-missing="true"]::after {
|
||||
content: "No preview";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Drag handle grip */
|
||||
.dragHandle {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 1;
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Actions Overlay */
|
||||
.actionsOverlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 44px; /* just below header */
|
||||
background: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
animation: slideDown 140ms ease-out;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-8px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.actionRow:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.actionDanger {
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
.actionsDivider {
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Pin indicator */
|
||||
.pinIndicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
background: #6B7280;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.menuDropdown {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
DARK MODE OVERRIDES
|
||||
========================= */
|
||||
:global([data-mantine-color-scheme="dark"]) .card {
|
||||
outline-color: #3A4047; /* deselected stroke */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
|
||||
outline-color: #4B525A; /* selected stroke (subtle grey) */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .headerResting {
|
||||
background: #1F2329; /* requested default unselected color */
|
||||
color: var(--tool-header-text); /* #D0D6DC */
|
||||
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .headerSelected {
|
||||
background: var(--tool-header-border); /* #3A4047 */
|
||||
color: var(--tool-header-text); /* #D0D6DC */
|
||||
border-bottom-color: var(--tool-header-border);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .title {
|
||||
color: #D0D6DC; /* title text */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .meta {
|
||||
color: #6B7280; /* subtitle text */
|
||||
}
|
||||
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
}
|
||||
@@ -1,42 +1,28 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { FileOperation } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import styles from '../pageEditor/PageEditor.module.css';
|
||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
onOpenPageEditor?: () => void;
|
||||
onMergeFiles?: (files: StirlingFile[]) => void;
|
||||
toolMode?: boolean;
|
||||
showUpload?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
supportedExtensions?: string[];
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
toolMode = false,
|
||||
showUpload = true,
|
||||
showBulkActions = true,
|
||||
supportedExtensions = ["pdf"]
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||
@@ -47,17 +33,14 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const isProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get the real context actions
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
@@ -87,19 +70,19 @@ const FileEditor = ({
|
||||
});
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
|
||||
// Create refs for frequently changing values to stabilize callbacks
|
||||
const contextSelectedIdsRef = useRef<string[]>([]);
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeFileRecords directly - no conversion needed
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
// Helper to convert FileRecord to FileThumbnail format
|
||||
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||
const recordToFileItem = useCallback((record: any) => {
|
||||
const file = selectors.getFile(record.id);
|
||||
if (!file) return null;
|
||||
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: file.name,
|
||||
@@ -161,29 +144,9 @@ const FileEditor = ({
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
fileIds: extractionResult.extractedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
extractionType: 'zip',
|
||||
extractedCount: extractionResult.extractedCount,
|
||||
totalFiles: extractionResult.totalFiles
|
||||
}
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
};
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
@@ -213,25 +176,6 @@ const FileEditor = ({
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Record upload operations for PDF files
|
||||
for (const file of allExtractedFiles) {
|
||||
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'upload',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [file.name],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
uploadMethod: 'drag-drop'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
@@ -240,7 +184,7 @@ const FileEditor = ({
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
@@ -252,33 +196,16 @@ const FileEditor = ({
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
|
||||
}, [activeFileRecords, setSelectedFiles]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
||||
|
||||
const closeAllFiles = useCallback(() => {
|
||||
if (activeFileRecords.length === 0) return;
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
const allFileIds = activeFileRecords.map(record => record.id);
|
||||
removeFiles(allFileIds, false); // false = keep in storage
|
||||
|
||||
// Clear selections
|
||||
setSelectedFiles([]);
|
||||
}, [activeFileRecords, removeFiles, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
||||
|
||||
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: string[];
|
||||
let newSelection: FileId[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
@@ -287,7 +214,7 @@ const FileEditor = ({
|
||||
// Add file to selection
|
||||
// In tool mode, typically allow multiple files unless specified otherwise
|
||||
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
|
||||
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
@@ -302,43 +229,34 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
setSelectedFiles([]);
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, [setSelectedFiles]);
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
|
||||
const currentIds = activeFileRecords.map(r => r.id);
|
||||
|
||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||
const targetIndex = currentIds.findIndex(id => id === targetFileId);
|
||||
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
console.warn('Could not find source or target file for reordering');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
? selectedFileIds.filter(id => currentIds.includes(id))
|
||||
: [sourceFileId];
|
||||
|
||||
// Create new order
|
||||
const newOrder = [...currentIds];
|
||||
|
||||
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
|
||||
.sort((a, b) => b - a); // Sort descending
|
||||
|
||||
|
||||
sourceIndices.forEach(index => {
|
||||
newOrder.splice(index, 1);
|
||||
});
|
||||
@@ -368,73 +286,36 @@ const FileEditor = ({
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeFileRecords, reorderFiles, setStatus]);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
|
||||
if (record && file) {
|
||||
// Record close operation
|
||||
const fileName = file.name;
|
||||
const contextFileId = record.id;
|
||||
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'remove',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [fileName],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: fileName,
|
||||
fileSize: record.size,
|
||||
parameters: {
|
||||
action: 'close',
|
||||
reason: 'user_request'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
const contextFileId = record.id;
|
||||
removeFiles([contextFileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
setSelectedFiles([fileId]);
|
||||
navActions.setMode('viewer');
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeFileRecords, setSelectedFiles, navActions.setMode]);
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
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: string) => {
|
||||
const file = selectors.getFile(fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file);
|
||||
}
|
||||
}, [selectors, onOpenPageEditor]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
@@ -454,7 +335,6 @@ const FileEditor = ({
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{
|
||||
height: '100vh',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent'
|
||||
@@ -462,28 +342,13 @@ const FileEditor = ({
|
||||
activateOnClick={false}
|
||||
activateOnDrag={true}
|
||||
>
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
{toolMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
{showBulkActions && !toolMode && (
|
||||
<>
|
||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
||||
Close All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
@@ -491,7 +356,7 @@ const FileEditor = ({
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
@@ -529,25 +394,25 @@ const FileEditor = ({
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
rowGap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{activeFileRecords.map((record, index) => {
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
|
||||
return (
|
||||
<FileThumbnail
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={fileItem}
|
||||
index={index}
|
||||
totalFiles={activeFileRecords.length}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
@@ -573,25 +438,29 @@ const FileEditor = ({
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
<Portal>
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
</Dropzone>
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string | null;
|
||||
size: number;
|
||||
modifiedAt?: number | string | Date;
|
||||
}
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: FileItem;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
|
||||
const FileEditorThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
// Prefer parent-provided handler if available
|
||||
if (typeof onDownloadFile === 'function') {
|
||||
onDownloadFile(file.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: attempt to download using the File object if provided
|
||||
const maybeFile = (file as unknown as { file?: File }).file;
|
||||
if (maybeFile instanceof File) {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(maybeFile);
|
||||
link.download = maybeFile.name || file.name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't find a way to download, surface a status message
|
||||
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
||||
}, [file, onDownloadFile, onSetStatus, t]);
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Meta formatting ----
|
||||
const prettySize = useMemo(() => {
|
||||
const bytes = file.size ?? 0;
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}, [file.size]);
|
||||
|
||||
const extUpper = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
||||
return (m?.[1] || '').toUpperCase();
|
||||
}, [file.name]);
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
file.pageCount > 0
|
||||
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
: '',
|
||||
[file.pageCount]
|
||||
);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
const d =
|
||||
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(d);
|
||||
}, [file.modifiedAt]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id
|
||||
}),
|
||||
canDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
|
||||
// Update dropdown width on resize
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||
useEffect(() => {
|
||||
if (!showActions) return;
|
||||
|
||||
const isInsideCard = (target: EventTarget | null) => {
|
||||
const container = dragElementRef.current;
|
||||
if (!container) return false;
|
||||
return target instanceof Node && container.contains(target);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// On touch devices, close if the touch target is outside the card
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [showActions]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>
|
||||
{t('unsupported', 'Unsupported')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
{showActions && (
|
||||
<div
|
||||
className={styles.actionsOverlay}
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => {
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
textAlign: 'center',
|
||||
background: 'var(--file-card-bg)',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={styles.meta}
|
||||
lineClamp={3}
|
||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||
>
|
||||
{/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ''}
|
||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
img.style.display = 'none';
|
||||
img.parentElement?.setAttribute('data-thumb-missing', 'true');
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
maxHeight: '80%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 0,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--border-default)',
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pin indicator (bottom-left) */}
|
||||
{isPinned && (
|
||||
<span className={styles.pinIndicator} aria-hidden>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FileEditorThumbnail);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Button, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||
@@ -11,27 +11,26 @@ interface FileDetailsProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
compact = false
|
||||
}) => {
|
||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
|
||||
// Get the currently displayed file
|
||||
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasMultipleFiles = selectedFiles.length > 1;
|
||||
|
||||
// Use IndexedDB hook for the current file
|
||||
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||
|
||||
|
||||
// Get thumbnail for current file
|
||||
const getCurrentThumbnail = () => {
|
||||
return currentThumbnail;
|
||||
};
|
||||
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
@@ -40,7 +39,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
|
||||
const handleNext = () => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
@@ -49,14 +48,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
|
||||
// Reset index when selection changes
|
||||
React.useEffect(() => {
|
||||
if (currentFileIndex >= selectedFiles.length) {
|
||||
setCurrentFileIndex(0);
|
||||
}
|
||||
}, [selectedFiles.length, currentFileIndex]);
|
||||
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactFileDetails
|
||||
@@ -88,26 +87,26 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
onNext={handleNext}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Section 2: File Details */}
|
||||
<FileInfoCard
|
||||
currentFile={currentFile}
|
||||
modalHeight={modalHeight}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
mb="xl"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||
color: 'white'
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
{selectedFiles.length > 1
|
||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||
: t('fileManager.openFile', 'Open File')
|
||||
}
|
||||
@@ -116,4 +115,4 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDetails;
|
||||
export default FileDetails;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Stack, Box } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import FileSourceButtons from './FileSourceButtons';
|
||||
import FileDetails from './FileDetails';
|
||||
import SearchInput from './SearchInput';
|
||||
@@ -19,14 +19,14 @@ const MobileLayout: React.FC = () => {
|
||||
const calculateFileListHeight = () => {
|
||||
// Base modal height minus padding and gaps
|
||||
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
||||
|
||||
|
||||
// Estimate heights of fixed components
|
||||
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
||||
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
||||
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
|
||||
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
||||
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
|
||||
|
||||
|
||||
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
|
||||
};
|
||||
|
||||
@@ -36,15 +36,15 @@ const MobileLayout: React.FC = () => {
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileSourceButtons horizontal={true} />
|
||||
</Box>
|
||||
|
||||
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileDetails compact={true} />
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
||||
<Box style={{
|
||||
<Box style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--bg-file-list)',
|
||||
borderRadius: '0.5rem',
|
||||
@@ -54,13 +54,13 @@ const MobileLayout: React.FC = () => {
|
||||
}}>
|
||||
{activeSource === 'recent' && (
|
||||
<>
|
||||
<Box style={{
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</Box>
|
||||
<Box style={{
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
@@ -68,11 +68,11 @@ const MobileLayout: React.FC = () => {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<FileListArea
|
||||
scrollAreaHeight={calculateFileListHeight()}
|
||||
scrollAreaStyle={{
|
||||
scrollAreaStyle={{
|
||||
height: calculateFileListHeight(),
|
||||
maxHeight: '60vh',
|
||||
minHeight: '9.375rem',
|
||||
@@ -83,11 +83,11 @@ const MobileLayout: React.FC = () => {
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Hidden file input for local file selection */}
|
||||
<HiddenFileInput />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileLayout;
|
||||
export default MobileLayout;
|
||||
|
||||
@@ -1,180 +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';
|
||||
|
||||
interface FileOperationHistoryProps {
|
||||
fileId: string;
|
||||
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: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
|
||||
const getAppliedOperations = (fileId: string) => [];
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,21 @@
|
||||
.workbench-scrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
@@ -1,31 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileState, useFileActions } from '../../contexts/FileContext';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { useToolManagement } from '../../hooks/useToolManagement';
|
||||
import './Workbench.css';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
import FileEditor from '../fileEditor/FileEditor';
|
||||
import PageEditor from '../pageEditor/PageEditor';
|
||||
import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||
import Viewer from '../viewer/Viewer';
|
||||
import ToolRenderer from '../tools/ToolRenderer';
|
||||
import LandingPage from '../shared/LandingPage';
|
||||
import Footer from '../shared/Footer';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const { currentMode: currentView } = useNavigationState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setMode;
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = state.files.ids;
|
||||
const {
|
||||
previewFile,
|
||||
@@ -36,7 +34,14 @@ export default function Workbench() {
|
||||
setSidebarsVisible
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { selectedToolKey, selectedTool, handleToolSelect } = useToolWorkflow();
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
|
||||
// Get navigation state - this is the source of truth
|
||||
const { selectedTool: selectedToolId } = useNavigationState();
|
||||
|
||||
// Get tool registry to look up selected tool
|
||||
const { toolRegistry } = useToolManagement();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addToActiveFiles } = useFileHandler();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
@@ -69,12 +74,10 @@ export default function Workbench() {
|
||||
case "fileEditor":
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolKey}
|
||||
showUpload={true}
|
||||
showBulkActions={!selectedToolKey}
|
||||
toolMode={!!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
{...(!selectedToolKey && {
|
||||
onOpenPageEditor: (file) => {
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
@@ -111,25 +114,22 @@ export default function Workbench() {
|
||||
onRotate={pageEditorFunctions.handleRotate}
|
||||
onDelete={pageEditorFunctions.handleDelete}
|
||||
onSplit={pageEditorFunctions.handleSplit}
|
||||
onExportSelected={pageEditorFunctions.onExportSelected}
|
||||
onSplitAll={pageEditorFunctions.handleSplitAll}
|
||||
onPageBreak={pageEditorFunctions.handlePageBreak}
|
||||
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
|
||||
onExportAll={pageEditorFunctions.onExportAll}
|
||||
exportLoading={pageEditorFunctions.exportLoading}
|
||||
selectionMode={pageEditorFunctions.selectionMode}
|
||||
selectedPages={pageEditorFunctions.selectedPages}
|
||||
selectedPageIds={pageEditorFunctions.selectedPageIds}
|
||||
displayDocument={pageEditorFunctions.displayDocument}
|
||||
splitPositions={pageEditorFunctions.splitPositions}
|
||||
totalPages={pageEditorFunctions.totalPages}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
// Check if it's a tool view
|
||||
if (selectedToolKey && selectedTool) {
|
||||
return (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LandingPage/>
|
||||
);
|
||||
@@ -138,7 +138,7 @@ export default function Workbench() {
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {} // No background color in rainbow mode
|
||||
@@ -149,18 +149,21 @@ export default function Workbench() {
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolKey}
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10"
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer analyticsEnabled />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
||||
import { Group, TextInput, Button, Text } from '@mantine/core';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPages: number[];
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
onUpdatePagesFromCSV: () => void;
|
||||
}
|
||||
|
||||
const BulkSelectionPanel = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPages,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
onUpdatePagesFromCSV,
|
||||
}: BulkSelectionPanelProps) => {
|
||||
return (
|
||||
<Paper p="md" mb="md" withBorder>
|
||||
<>
|
||||
<Group>
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
@@ -30,12 +32,15 @@ const BulkSelectionPanel = ({
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
{selectedPages.length > 0 && (
|
||||
{selectedPageIds.length > 0 && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
Selected: {selectedPages.length} pages
|
||||
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(n => n > 0).join(', ') : ''})
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,97 +1,100 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import styles from './PageEditor.module.css';
|
||||
import { GRID_CONSTANTS } from './constants';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitBefore?: boolean;
|
||||
splitAfter?: boolean;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: number[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
selectedItems,
|
||||
selectionMode,
|
||||
isAnimating,
|
||||
onReorderPages,
|
||||
renderItem,
|
||||
renderSplitMarker,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// Responsive grid configuration
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const ITEM_WIDTH = 320; // 20rem (page width)
|
||||
const ITEM_GAP = 24; // 1.5rem gap between items
|
||||
const ITEM_HEIGHT = 340; // 20rem + gap
|
||||
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
|
||||
|
||||
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const calculateItemsPerRow = useCallback(() => {
|
||||
if (!containerRef.current) return 4; // Default fallback
|
||||
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
if (containerWidth === 0) return 4; // Container not measured yet
|
||||
|
||||
|
||||
// Convert rem to pixels for calculation
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
|
||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||
|
||||
|
||||
return Math.max(1, calculated); // At least 1 item per row
|
||||
}, []);
|
||||
|
||||
|
||||
// Update items per row when container resizes
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
const newItemsPerRow = calculateItemsPerRow();
|
||||
setItemsPerRow(newItemsPerRow);
|
||||
};
|
||||
|
||||
|
||||
// Initial calculation
|
||||
updateLayout();
|
||||
|
||||
|
||||
// Listen for window resize
|
||||
window.addEventListener('resize', updateLayout);
|
||||
|
||||
|
||||
// Use ResizeObserver for container size changes
|
||||
const resizeObserver = new ResizeObserver(updateLayout);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateItemsPerRow]);
|
||||
|
||||
|
||||
// Virtualization with react-virtual library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: Math.ceil(items.length / itemsPerRow),
|
||||
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
estimateSize: () => {
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
},
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
|
||||
// Calculate optimal width for centering
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -102,13 +105,15 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
margin: '0 auto',
|
||||
maxWidth: `${gridWidth}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||
const rowItems = items.slice(startIndex, endIndex);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
@@ -124,24 +129,23 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Split marker */}
|
||||
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
|
||||
import styles from './PageEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
id: FileId;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
thumbnail: string | null;
|
||||
size: number;
|
||||
splitBefore?: boolean;
|
||||
modifiedAt?: number | string | Date;
|
||||
}
|
||||
|
||||
interface FileThumbnailProps {
|
||||
@@ -24,11 +28,12 @@ interface FileThumbnailProps {
|
||||
totalFiles: number;
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -36,41 +41,63 @@ interface FileThumbnailProps {
|
||||
const FileThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
totalFiles,
|
||||
selectedFiles,
|
||||
selectionMode,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
toolMode = false,
|
||||
onDownloadFile,
|
||||
isSupported = true,
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
|
||||
// Drag and drop state
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
// Find the actual File object that corresponds to this FileItem
|
||||
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
// Prefer parent-provided handler if available
|
||||
if (typeof onDownloadFile === 'function') {
|
||||
onDownloadFile(file.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
|
||||
// Fallback: attempt to download using the File object if provided
|
||||
const maybeFile = (file as unknown as { file?: File }).file;
|
||||
if (maybeFile instanceof File) {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(maybeFile);
|
||||
link.download = maybeFile.name || file.name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't find a way to download, surface a status message
|
||||
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
||||
}, [file, onDownloadFile, onSetStatus, t]);
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
|
||||
|
||||
dragElementRef.current = element;
|
||||
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
@@ -86,7 +113,7 @@ const FileThumbnail = ({
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
@@ -100,8 +127,8 @@ const FileThumbnail = ({
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as string;
|
||||
const selectedFileIds = sourceData.selectedFiles as string[];
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
@@ -111,69 +138,162 @@ const FileThumbnail = ({
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
|
||||
// Update dropdown width on resize
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||
useEffect(() => {
|
||||
if (!showActions) return;
|
||||
|
||||
const isInsideCard = (target: EventTarget | null) => {
|
||||
const container = dragElementRef.current;
|
||||
if (!container) return false;
|
||||
return target instanceof Node && container.contains(target);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// On touch devices, close if the touch target is outside the card
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [showActions]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
cursor-grab
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[24rem]
|
||||
flex flex-col items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
`}
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
data-testid="file-thumbnail-checkbox"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 4,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>
|
||||
{t('unsupported', 'Unsupported')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFiles.includes(file.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
if (isSupported) {
|
||||
onToggleFile(file.id);
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
{showActions && (
|
||||
<div
|
||||
className={styles.actionsOverlay}
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => {
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!isSupported}
|
||||
size="sm"
|
||||
/>
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -195,170 +315,46 @@ const FileThumbnail = ({
|
||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
onError={(e) => {
|
||||
// Hide broken image if blob URL was revoked
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
}}
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
onError={(e) => {
|
||||
// Hide broken image if blob URL was revoked
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
maxWidth: '80%',
|
||||
maxHeight: '80%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
borderRadius: 0,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--border-default)',
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page count badge - only show for PDFs */}
|
||||
{file.pageCount > 0 && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Unsupported badge */}
|
||||
{!isSupported && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: selectionMode ? 48 : 8, // Avoid overlap with checkbox
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{t("fileManager.unsupported", "Unsupported")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* File name overlay */}
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
size="xs"
|
||||
fw={500}
|
||||
c="white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
right: 5,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
{/* Hover controls */}
|
||||
<div
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
|
||||
{actualFile && (
|
||||
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c={isFilePinned(actualFile) ? "yellow" : "white"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isFilePinned(actualFile)) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus(`Unpinned ${file.name}`);
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus(`Pinned ${file.name}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFilePinned(actualFile) ? (
|
||||
<PushPinIcon style={{ fontSize: 20 }} />
|
||||
) : (
|
||||
<PushPinOutlinedIcon style={{ fontSize: 20 }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label="Close File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="orange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Closed ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<DragIndicatorIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
fontSize: 16,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Pin indicator (bottom-left) */}
|
||||
{isPinned && (
|
||||
<span className={styles.pinIndicator} aria-hidden>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* File info */}
|
||||
<div className="w-full px-4 py-2 text-center">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatFileSize(file.size)}
|
||||
</Text>
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileThumbnail;
|
||||
export default React.memo(FileThumbnail);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageHoverControls {
|
||||
opacity: 1 !important;
|
||||
opacity: 0.95 !important;
|
||||
}
|
||||
|
||||
/* Checkbox container - prevent transform inheritance */
|
||||
@@ -62,6 +62,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
/* Action styles */
|
||||
.actionRow:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.actionDanger {
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
.actionsDivider {
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.pinIndicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.35); /* match drag handle color */
|
||||
}
|
||||
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
background: #6B7280;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,14 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Paper
|
||||
} from "@mantine/core";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
// Close/Reset functions
|
||||
@@ -27,19 +24,25 @@ interface PageEditorControlsProps {
|
||||
onRotate: (direction: 'left' | 'right') => void;
|
||||
onDelete: () => void;
|
||||
onSplit: () => void;
|
||||
onSplitAll: () => void;
|
||||
onPageBreak: () => void;
|
||||
onPageBreakAll: () => void;
|
||||
|
||||
// Export functions
|
||||
onExportSelected: () => void;
|
||||
// Export functions (moved to right rail)
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPages: number[];
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
|
||||
// Split state (for tooltip logic)
|
||||
splitPositions?: Set<number>;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
onClosePdf,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
@@ -47,19 +50,62 @@ const PageEditorControls = ({
|
||||
onRotate,
|
||||
onDelete,
|
||||
onSplit,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages
|
||||
onPageBreak,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
totalPages
|
||||
}: PageEditorControlsProps) => {
|
||||
// Calculate split tooltip text using smart toggle logic
|
||||
const getSplitTooltip = () => {
|
||||
if (!splitPositions || !totalPages || selectedPageIds.length === 0) {
|
||||
return "Split Selected";
|
||||
}
|
||||
|
||||
// Convert selected pages to split positions (same logic as handleSplit)
|
||||
const selectedPageNumbers = displayDocument ? selectedPageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0) : [];
|
||||
const selectedSplitPositions = selectedPageNumbers.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1);
|
||||
|
||||
if (selectedSplitPositions.length === 0) {
|
||||
return "Split Selected";
|
||||
}
|
||||
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedSplitPositions.length - existingSplitsCount;
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
if (willRemoveSplits) {
|
||||
return existingSplitsCount === selectedSplitPositions.length
|
||||
? "Remove All Selected Splits"
|
||||
: "Remove Selected Splits";
|
||||
} else {
|
||||
return existingSplitsCount === 0
|
||||
? "Split Selected"
|
||||
: "Complete Selected Splits";
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate page break tooltip text
|
||||
const getPageBreakTooltip = () => {
|
||||
return selectedPageIds.length > 0
|
||||
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
|
||||
: "Insert Page Breaks";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '20px',
|
||||
transform: 'translateX(-50%)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
@@ -67,43 +113,37 @@ const PageEditorControls = ({
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
radius="xl"
|
||||
shadow="lg"
|
||||
p={16}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 32,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 400,
|
||||
justifyContent: 'center'
|
||||
minWidth: 360,
|
||||
maxWidth: 700,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
padding: "1rem",
|
||||
paddingBottom: "1rem"
|
||||
}}
|
||||
>
|
||||
{/* Close PDF */}
|
||||
<Tooltip label="Close PDF">
|
||||
<ActionIcon
|
||||
onClick={onClosePdf}
|
||||
color="red"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -111,79 +151,67 @@ const PageEditorControls = ({
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
|
||||
<Tooltip label="Rotate Selected Left">
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
|
||||
<Tooltip label="Rotate Selected Right">
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
|
||||
<Tooltip label="Delete Selected">
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
color="red"
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||
<Tooltip label={getSplitTooltip()}>
|
||||
<ActionIcon
|
||||
onClick={onSplit}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<ContentCutIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Export Controls */}
|
||||
{selectionMode && selectedPages.length > 0 && (
|
||||
<Tooltip label="Export Selected">
|
||||
<ActionIcon
|
||||
onClick={onExportSelected}
|
||||
disabled={exportLoading}
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Export All">
|
||||
<Tooltip label={getPageBreakTooltip()}>
|
||||
<ActionIcon
|
||||
onClick={onExportAll}
|
||||
disabled={exportLoading}
|
||||
color="green"
|
||||
variant="light"
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<InsertPageBreakIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,169 +1,177 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
||||
import { Command } from '../../hooks/useUndoRedo';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
|
||||
interface PageThumbnailProps {
|
||||
page: PDFPage;
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File; // For lazy thumbnail generation
|
||||
selectedPages: number[];
|
||||
originalFile?: File;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onTogglePage: (pageNumber: number) => void;
|
||||
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
||||
onExecuteCommand: (command: Command) => void;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
onExecuteCommand: (command: { execute: () => void }) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (pageNumber: number | null) => void;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||
RotatePagesCommand: typeof RotatePagesCommand;
|
||||
DeletePagesCommand: typeof DeletePagesCommand;
|
||||
ToggleSplitCommand: typeof ToggleSplitCommand;
|
||||
onSetMovingPage: (page: number | null) => void;
|
||||
onDeletePage: (pageNumber: number) => void;
|
||||
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
|
||||
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
|
||||
createSplitCommand: (position: number) => { execute: () => void };
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
splitPositions: Set<number>;
|
||||
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
|
||||
}
|
||||
|
||||
const PageThumbnail = React.memo(({
|
||||
const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
page,
|
||||
index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
selectedPages,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
pageRefs,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onAnimateReorder,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
onSetMovingPage,
|
||||
onReorderPages,
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ToggleSplitCommand,
|
||||
onDeletePage,
|
||||
createRotateCommand,
|
||||
createSplitCommand,
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
}: PageThumbnailProps) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const { state, selectors } = useFileState();
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
// Update thumbnail URL when page prop changes - prevent redundant updates
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
|
||||
if (firstRealPage?.thumbnail) {
|
||||
// Try to get aspect ratio from an actual thumbnail image
|
||||
// For now, default to A4 but could be enhanced to measure image dimensions
|
||||
return '1 / 1.414'; // A4 ratio as fallback
|
||||
}
|
||||
return '1 / 1.414'; // Default A4 ratio
|
||||
}, [pdfDocument.pages]);
|
||||
|
||||
// Update thumbnail URL when page prop changes
|
||||
useEffect(() => {
|
||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
||||
setThumbnailUrl(page.thumbnail);
|
||||
}
|
||||
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
|
||||
}, [page.thumbnail, thumbnailUrl]);
|
||||
|
||||
// Request thumbnail generation if not available (optimized for performance)
|
||||
// Request thumbnail if missing (on-demand, virtualized approach)
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl || !originalFile) {
|
||||
return; // Skip if we already have a thumbnail or no original file
|
||||
let isCancelled = false;
|
||||
|
||||
// If we already have a thumbnail, use it
|
||||
if (page.thumbnail) {
|
||||
setThumbnailUrl(page.thumbnail);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first without async call
|
||||
// Check cache first
|
||||
const cachedThumbnail = getThumbnailFromCache(page.id);
|
||||
if (cachedThumbnail) {
|
||||
setThumbnailUrl(cachedThumbnail);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
// Request thumbnail generation if we have the original file
|
||||
if (originalFile) {
|
||||
const pageNumber = page.originalPageNumber;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
try {
|
||||
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber);
|
||||
|
||||
// Only update if component is still mounted and we got a result
|
||||
if (!cancelled && thumbnail) {
|
||||
setThumbnailUrl(thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
requestThumbnail(page.id, originalFile, pageNumber)
|
||||
.then(thumbnail => {
|
||||
if (!isCancelled && thumbnail) {
|
||||
setThumbnailUrl(thumbnail);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(`Failed to generate thumbnail for ${page.id}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
loadThumbnail();
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
cancelled = true;
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
|
||||
|
||||
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
||||
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
|
||||
? selectedPages
|
||||
: [page.pageNumber]
|
||||
selectedPageIds: [page.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
|
||||
? selectedPages
|
||||
: undefined;
|
||||
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
|
||||
onReorderPages(page.pageNumber, targetIndex, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: ({ source }) => {}
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
@@ -174,17 +182,105 @@ const PageThumbnail = React.memo(({
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
// DOM command handlers
|
||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Use the command system for undo/redo support
|
||||
const command = createRotateCommand([page.id], -90);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
|
||||
|
||||
const handleRotateRight = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Use the command system for undo/redo support
|
||||
const command = createRotateCommand([page.id], 90);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
|
||||
|
||||
const handleDelete = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDeletePage(page.pageNumber);
|
||||
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||
}, [page.pageNumber, onDeletePage, onSetStatus]);
|
||||
|
||||
const handleSplit = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Create a command to toggle split at this position
|
||||
const command = createSplitCommand(index);
|
||||
onExecuteCommand(command);
|
||||
|
||||
const hasSplit = splitPositions.has(index);
|
||||
const action = hasSplit ? 'removed' : 'added';
|
||||
onSetStatus(`Split marker ${action} after position ${index + 1}`);
|
||||
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||
|
||||
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onInsertFiles) {
|
||||
// Open file manager modal with custom handler for page insertion
|
||||
openFilesModal({
|
||||
insertAfterPage: page.pageNumber,
|
||||
customHandler: (files: File[], insertAfterPage?: number) => {
|
||||
if (insertAfterPage !== undefined) {
|
||||
onInsertFiles(files, insertAfterPage);
|
||||
}
|
||||
}
|
||||
});
|
||||
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
||||
} else {
|
||||
// Fallback to normal file handling
|
||||
openFilesModal({ insertAfterPage: page.pageNumber });
|
||||
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
||||
}
|
||||
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
|
||||
|
||||
// Handle click vs drag differentiation
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
setIsMouseDown(true);
|
||||
setMouseStartPos({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
if (!isMouseDown || !mouseStartPos) {
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate distance moved
|
||||
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
||||
if (distance < 5 && !isDragging) {
|
||||
onTogglePage(page.id);
|
||||
}
|
||||
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
cursor-grab
|
||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
@@ -204,8 +300,11 @@ const PageThumbnail = React.memo(({
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{selectionMode && (
|
||||
{
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
@@ -213,33 +312,32 @@ const PageThumbnail = React.memo(({
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid #ccc',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer'
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePage(page.id);
|
||||
}}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePage(page.pageNumber);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
|
||||
checked={Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false}
|
||||
onChange={() => {
|
||||
// onChange is handled by the parent div click
|
||||
// Selection is handled by container mouseDown
|
||||
}}
|
||||
size="sm"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||
<div
|
||||
@@ -255,7 +353,23 @@ const PageThumbnail = React.memo(({
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
{page.isBlankPage ? (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '70%',
|
||||
aspectRatio: getDocumentAspectRatio(),
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 2
|
||||
}}></div>
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
@@ -281,12 +395,12 @@ const PageThumbnail = React.memo(({
|
||||
className={styles.pageNumber}
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="white"
|
||||
style={{
|
||||
color: 'var(--mantine-color-white)', // Use theme token for consistency
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
background: 'rgba(162, 201, 255, 0.8)',
|
||||
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
@@ -304,7 +418,8 @@ const PageThumbnail = React.memo(({
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
@@ -315,19 +430,23 @@ const PageThumbnail = React.memo(({
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label="Move Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
disabled={index === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onAnimateReorder(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
// Actually move the page left (swap with previous page)
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
}}
|
||||
@@ -340,14 +459,15 @@ const PageThumbnail = React.memo(({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
disabled={index === totalPages - 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onAnimateReorder(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
// Actually move the page right (swap with next page)
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
}}
|
||||
@@ -360,18 +480,8 @@ const PageThumbnail = React.memo(({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id],
|
||||
-90
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||
}}
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleRotateLeft}
|
||||
>
|
||||
<RotateLeftIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
@@ -381,18 +491,8 @@ const PageThumbnail = React.memo(({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id],
|
||||
90
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||
}}
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleRotateRight}
|
||||
>
|
||||
<RotateRightIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
@@ -403,66 +503,41 @@ const PageThumbnail = React.memo(({
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new DeletePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id]
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||
}}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{index > 0 && (
|
||||
<Tooltip label="Split Here">
|
||||
{index < totalPages - 1 && (
|
||||
<Tooltip label="Split After">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new ToggleSplitCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id]
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
|
||||
}}
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleSplit}
|
||||
>
|
||||
<ContentCutIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label="Insert File After">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleInsertFileAfter}
|
||||
>
|
||||
<AddIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Helper for shallow array comparison
|
||||
const arraysEqual = (a: number[], b: number[]) => {
|
||||
return a.length === b.length && a.every((val, i) => val === b[i]);
|
||||
};
|
||||
|
||||
// Only re-render if essential props change
|
||||
return (
|
||||
prevProps.page.id === nextProps.page.id &&
|
||||
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
||||
prevProps.page.rotation === nextProps.page.rotation &&
|
||||
prevProps.page.thumbnail === nextProps.page.thumbnail &&
|
||||
// Shallow compare selectedPages array for better stability
|
||||
(prevProps.selectedPages === nextProps.selectedPages ||
|
||||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
|
||||
prevProps.selectionMode === nextProps.selectionMode &&
|
||||
prevProps.movingPage === nextProps.movingPage &&
|
||||
prevProps.isAnimating === nextProps.isAnimating
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default PageThumbnail;
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
import { FileId } from '../../../types/file';
|
||||
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
|
||||
|
||||
// V1-style DOM-first command system (replaces the old React state commands)
|
||||
export abstract class DOMCommand {
|
||||
abstract execute(): void;
|
||||
abstract undo(): void;
|
||||
abstract description: string;
|
||||
}
|
||||
|
||||
export class RotatePageCommand extends DOMCommand {
|
||||
constructor(
|
||||
private pageId: string,
|
||||
private degrees: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Only update DOM for immediate visual feedback
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property to match the animated CSS
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Only update DOM
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const previousRotation = currentRotation - this.degrees;
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeletePagesCommand extends DOMCommand {
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
private originalSelectedPages: number[] = [];
|
||||
private hasExecuted: boolean = false;
|
||||
private pageIdsToDelete: string[] = [];
|
||||
|
||||
constructor(
|
||||
private pagesToDelete: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void,
|
||||
private getSelectedPages: () => number[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.pagesToDelete.length === 0) return;
|
||||
|
||||
// Store complete original state for undo (only on first execution)
|
||||
if (!this.hasExecuted) {
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages
|
||||
};
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
this.originalSelectedPages = [...this.getSelectedPages()];
|
||||
|
||||
// Convert page numbers to page IDs for stable identification
|
||||
this.pageIdsToDelete = this.pagesToDelete.map(pageNum => {
|
||||
const page = currentDoc.pages.find(p => p.pageNumber === pageNum);
|
||||
return page?.id || '';
|
||||
}).filter(id => id);
|
||||
|
||||
this.hasExecuted = true;
|
||||
}
|
||||
|
||||
// Filter out deleted pages by ID (stable across undo/redo)
|
||||
const remainingPages = currentDoc.pages.filter(page =>
|
||||
!this.pageIdsToDelete.includes(page.id)
|
||||
);
|
||||
|
||||
if (remainingPages.length === 0) return; // Safety check
|
||||
|
||||
// Renumber remaining pages
|
||||
remainingPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: remainingPages,
|
||||
totalPages: remainingPages.length,
|
||||
};
|
||||
|
||||
// Adjust split positions
|
||||
const currentSplitPositions = this.getSplitPositions();
|
||||
const newPositions = new Set<number>();
|
||||
currentSplitPositions.forEach(pos => {
|
||||
if (pos < remainingPages.length - 1) {
|
||||
newPositions.add(pos);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply changes
|
||||
this.setDocument(updatedDocument);
|
||||
this.setSelectedPages([]);
|
||||
this.setSplitPositions(newPositions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
|
||||
// Simply restore the complete original document state
|
||||
this.setDocument(this.originalDocument);
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
this.setSelectedPages(this.originalSelectedPages);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Delete ${this.pagesToDelete.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ReorderPagesCommand extends DOMCommand {
|
||||
private originalPages: PDFPage[] = [];
|
||||
|
||||
constructor(
|
||||
private sourcePageNumber: number,
|
||||
private targetIndex: number,
|
||||
private selectedPages: number[] | undefined,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc) return;
|
||||
|
||||
// Store original state for undo
|
||||
this.originalPages = currentDoc.pages.map(page => ({...page}));
|
||||
|
||||
// Perform the reorder
|
||||
const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber);
|
||||
if (sourceIndex === -1) return;
|
||||
|
||||
const newPages = [...currentDoc.pages];
|
||||
|
||||
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
|
||||
// Multi-page reorder
|
||||
const selectedPageObjects = this.selectedPages
|
||||
.map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum))
|
||||
.filter(page => page !== undefined) as PDFPage[];
|
||||
|
||||
const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber));
|
||||
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
|
||||
|
||||
remainingPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
newPages.splice(0, newPages.length, ...remainingPages);
|
||||
} else {
|
||||
// Single page reorder
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
newPages.splice(this.targetIndex, 0, movedPage);
|
||||
|
||||
newPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
const reorderedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(reorderedDocument);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.originalPages.length === 0) return;
|
||||
|
||||
// Restore original page order
|
||||
const restoredDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: this.originalPages,
|
||||
totalPages: this.originalPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(restoredDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Reorder page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class SplitCommand extends DOMCommand {
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
|
||||
constructor(
|
||||
private position: number,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original state for undo
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
|
||||
// Toggle the split position
|
||||
const currentPositions = this.getSplitPositions();
|
||||
const newPositions = new Set(currentPositions);
|
||||
|
||||
if (newPositions.has(this.position)) {
|
||||
newPositions.delete(this.position);
|
||||
} else {
|
||||
newPositions.add(this.position);
|
||||
}
|
||||
|
||||
this.setSplitPositions(newPositions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Restore original split positions
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const currentPositions = this.getSplitPositions();
|
||||
const willAdd = !currentPositions.has(this.position);
|
||||
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkRotateCommand extends DOMCommand {
|
||||
private originalRotations: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
private pageIds: string[],
|
||||
private degrees: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.pageIds.forEach(pageId => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Store original rotation for undo (only on first execution)
|
||||
if (!this.originalRotations.has(pageId)) {
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
this.originalRotations.set(pageId, currentRotation);
|
||||
}
|
||||
|
||||
// Apply rotation using transform to trigger CSS animation
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.pageIds.forEach(pageId => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img && this.originalRotations.has(pageId)) {
|
||||
img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkSplitCommand extends DOMCommand {
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
|
||||
constructor(
|
||||
private positions: number[],
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original state for undo (only on first execution)
|
||||
if (this.originalSplitPositions.size === 0) {
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
}
|
||||
|
||||
// Toggle each position
|
||||
const currentPositions = new Set(this.getSplitPositions());
|
||||
this.positions.forEach(position => {
|
||||
if (currentPositions.has(position)) {
|
||||
currentPositions.delete(position);
|
||||
} else {
|
||||
currentPositions.add(position);
|
||||
}
|
||||
});
|
||||
|
||||
this.setSplitPositions(currentPositions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Restore original split positions
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Toggle ${this.positions.length} split position(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class SplitAllCommand extends DOMCommand {
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
private allPossibleSplits: Set<number> = new Set();
|
||||
|
||||
constructor(
|
||||
private totalPages: number,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void
|
||||
) {
|
||||
super();
|
||||
// Calculate all possible split positions (between pages, not after last page)
|
||||
for (let i = 0; i < this.totalPages - 1; i++) {
|
||||
this.allPossibleSplits.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original state for undo
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
|
||||
// Check if all splits are already active
|
||||
const currentSplits = this.getSplitPositions();
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
|
||||
|
||||
if (hasAllSplits) {
|
||||
// Remove all splits
|
||||
this.setSplitPositions(new Set());
|
||||
} else {
|
||||
// Add all splits
|
||||
this.setSplitPositions(this.allPossibleSplits);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Restore original split positions
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const currentSplits = this.getSplitPositions();
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
|
||||
return hasAllSplits ? 'Remove all splits' : 'Split all pages';
|
||||
}
|
||||
}
|
||||
|
||||
export class PageBreakCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
|
||||
constructor(
|
||||
private selectedPageNumbers: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.selectedPageNumbers.length === 0) return;
|
||||
|
||||
// Store original state for undo
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page}))
|
||||
};
|
||||
|
||||
// Create new pages array with blank pages inserted
|
||||
const newPages: PDFPage[] = [];
|
||||
this.insertedPages = [];
|
||||
let pageNumberCounter = 1;
|
||||
|
||||
currentDoc.pages.forEach((page, index) => {
|
||||
// Add the current page
|
||||
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
|
||||
newPages.push(updatedPage);
|
||||
|
||||
// If this page is selected for page break insertion, add a blank page after it
|
||||
if (this.selectedPageNumbers.includes(page.pageNumber)) {
|
||||
const blankPage: PDFPage = {
|
||||
id: `blank-${Date.now()}-${index}`,
|
||||
pageNumber: pageNumberCounter++,
|
||||
originalPageNumber: -1, // Mark as blank page
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: true // Custom flag for blank pages
|
||||
};
|
||||
newPages.push(blankPage);
|
||||
this.insertedPages.push(blankPage);
|
||||
}
|
||||
});
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
// No need to maintain selection - page IDs remain stable, so selection persists automatically
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Insert ${this.selectedPageNumbers.length} page break(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkPageBreakCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private originalSelectedPages: number[] = [];
|
||||
|
||||
constructor(
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSelectedPages: () => number[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc) return;
|
||||
|
||||
// Store original selection to restore later
|
||||
this.originalSelectedPages = this.getSelectedPages();
|
||||
|
||||
// Store original state for undo
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page}))
|
||||
};
|
||||
|
||||
// Create new pages array with blank pages inserted after each page (except the last)
|
||||
const newPages: PDFPage[] = [];
|
||||
this.insertedPages = [];
|
||||
let pageNumberCounter = 1;
|
||||
|
||||
currentDoc.pages.forEach((page, index) => {
|
||||
// Add the current page
|
||||
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
|
||||
newPages.push(updatedPage);
|
||||
|
||||
// Add blank page after each page except the last one
|
||||
if (index < currentDoc.pages.length - 1) {
|
||||
const blankPage: PDFPage = {
|
||||
id: `blank-${Date.now()}-${index}`,
|
||||
pageNumber: pageNumberCounter++,
|
||||
originalPageNumber: -1,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: true
|
||||
};
|
||||
newPages.push(blankPage);
|
||||
this.insertedPages.push(blankPage);
|
||||
}
|
||||
});
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
// Maintain existing selection by mapping original selected pages to their new positions
|
||||
const updatedSelection: number[] = [];
|
||||
this.originalSelectedPages.forEach(originalPageNum => {
|
||||
// Find the original page by matching the page ID from the original document
|
||||
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
|
||||
if (originalPage) {
|
||||
const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage);
|
||||
if (foundPage) {
|
||||
updatedSelection.push(foundPage.pageNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.setSelectedPages(updatedSelection);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Insert page breaks after all pages`;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsertFilesCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
|
||||
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
|
||||
private insertedFileMap = new Map<FileId, File>(); // Store inserted files for export
|
||||
|
||||
constructor(
|
||||
private files: File[],
|
||||
private insertAfterPageNumber: number,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.files.length === 0) return;
|
||||
|
||||
// Store original state for undo
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page}))
|
||||
};
|
||||
|
||||
try {
|
||||
// Process each file to extract pages and wait for all to complete
|
||||
const allNewPages: PDFPage[] = [];
|
||||
|
||||
// Process all files and wait for their completion
|
||||
const baseTimestamp = Date.now();
|
||||
const extractionPromises = this.files.map(async (file, index) => {
|
||||
const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId;
|
||||
// Store inserted file for export
|
||||
this.insertedFileMap.set(fileId, file);
|
||||
// Use base timestamp + index to ensure unique but predictable file IDs
|
||||
return await this.extractPagesFromFile(file, baseTimestamp + index);
|
||||
});
|
||||
|
||||
const extractedPageArrays = await Promise.all(extractionPromises);
|
||||
|
||||
// Flatten all extracted pages
|
||||
for (const pages of extractedPageArrays) {
|
||||
allNewPages.push(...pages);
|
||||
}
|
||||
|
||||
if (allNewPages.length === 0) return;
|
||||
|
||||
// Find insertion point (after the specified page)
|
||||
const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N
|
||||
|
||||
// Create new pages array with inserted pages
|
||||
const newPages: PDFPage[] = [];
|
||||
let pageNumberCounter = 1;
|
||||
|
||||
// Add pages before insertion point
|
||||
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
|
||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
||||
newPages.push(page);
|
||||
}
|
||||
|
||||
// Add inserted pages
|
||||
for (const newPage of allNewPages) {
|
||||
const insertedPage: PDFPage = {
|
||||
...newPage,
|
||||
pageNumber: pageNumberCounter++,
|
||||
selected: false,
|
||||
splitAfter: false
|
||||
};
|
||||
newPages.push(insertedPage);
|
||||
this.insertedPages.push(insertedPage);
|
||||
}
|
||||
|
||||
// Add remaining pages after insertion point
|
||||
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
|
||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
||||
newPages.push(page);
|
||||
}
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
// Update FileContext with the new document structure and inserted files
|
||||
if (this.updateFileContext) {
|
||||
this.updateFileContext(updatedDocument, this.insertedFileMap);
|
||||
}
|
||||
|
||||
// Generate thumbnails for inserted pages (all files should be read by now)
|
||||
this.generateThumbnailsForInsertedPages(updatedDocument);
|
||||
|
||||
// Maintain existing selection by mapping original selected pages to their new positions
|
||||
const originalSelection = this.getSelectedPages();
|
||||
const updatedSelection: number[] = [];
|
||||
|
||||
originalSelection.forEach(originalPageNum => {
|
||||
if (originalPageNum <= this.insertAfterPageNumber) {
|
||||
// Pages before insertion point keep same number
|
||||
updatedSelection.push(originalPageNum);
|
||||
} else {
|
||||
// Pages after insertion point are shifted by number of inserted pages
|
||||
updatedSelection.push(originalPageNum + allNewPages.length);
|
||||
}
|
||||
});
|
||||
|
||||
this.setSelectedPages(updatedSelection);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to insert files:', error);
|
||||
// Revert to original state if error occurs
|
||||
if (this.originalDocument) {
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
|
||||
try {
|
||||
const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService');
|
||||
|
||||
// Group pages by file ID to generate thumbnails efficiently
|
||||
const pagesByFileId = new Map<FileId, PDFPage[]>();
|
||||
|
||||
for (const page of this.insertedPages) {
|
||||
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */;
|
||||
if (!pagesByFileId.has(fileId)) {
|
||||
pagesByFileId.set(fileId, []);
|
||||
}
|
||||
pagesByFileId.get(fileId)!.push(page);
|
||||
}
|
||||
|
||||
// Generate thumbnails for each file
|
||||
for (const [fileId, pages] of pagesByFileId) {
|
||||
const arrayBuffer = this.fileDataMap.get(fileId);
|
||||
|
||||
console.log('Generating thumbnails for file:', fileId);
|
||||
console.log('Pages:', pages.length);
|
||||
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
|
||||
|
||||
if (arrayBuffer && arrayBuffer.byteLength > 0) {
|
||||
// Extract page numbers for all pages from this file
|
||||
const pageNumbers = pages.map(page => {
|
||||
const pageNumMatch = page.id.match(/-page-(\d+)$/);
|
||||
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
|
||||
});
|
||||
|
||||
console.log('Generating thumbnails for page numbers:', pageNumbers);
|
||||
|
||||
// Generate thumbnails for all pages from this file at once
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
arrayBuffer,
|
||||
pageNumbers,
|
||||
{ scale: 0.2, quality: 0.8 }
|
||||
);
|
||||
|
||||
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
|
||||
|
||||
// Update pages with generated thumbnails
|
||||
for (let i = 0; i < results.length && i < pages.length; i++) {
|
||||
const result = results[i];
|
||||
const page = pages[i];
|
||||
|
||||
if (result.success) {
|
||||
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
|
||||
if (pageIndex >= 0) {
|
||||
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
|
||||
console.log('Updated thumbnail for page:', page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger re-render by updating the document
|
||||
this.setDocument({ ...updatedDocument });
|
||||
} else {
|
||||
console.error('No valid ArrayBuffer found for file ID:', fileId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnails for inserted pages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
|
||||
|
||||
if (!arrayBuffer) {
|
||||
reject(new Error('Failed to read file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone the ArrayBuffer before passing to PDF.js (it might consume it)
|
||||
const clonedArrayBuffer = arrayBuffer.slice(0);
|
||||
|
||||
// Use PDF.js via the worker manager to extract pages
|
||||
const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const pages: PDFPage[] = [];
|
||||
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
|
||||
|
||||
console.log('Original ArrayBuffer size:', arrayBuffer.byteLength);
|
||||
console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength);
|
||||
|
||||
// Store the original ArrayBuffer for thumbnail generation
|
||||
this.fileDataMap.set(fileId, arrayBuffer);
|
||||
|
||||
console.log('After storing - fileDataMap size:', this.fileDataMap.size);
|
||||
console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined');
|
||||
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
const pageId = `${fileId}-page-${i}`;
|
||||
pages.push({
|
||||
id: pageId,
|
||||
pageNumber: i, // Will be renumbered in execute()
|
||||
originalPageNumber: i,
|
||||
thumbnail: null, // Will be generated after insertion
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: false
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up PDF document
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
resolve(pages);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple undo manager for DOM commands
|
||||
export class UndoManager {
|
||||
private undoStack: DOMCommand[] = [];
|
||||
private redoStack: DOMCommand[] = [];
|
||||
private onStateChange?: () => void;
|
||||
|
||||
setStateChangeCallback(callback: () => void): void {
|
||||
this.onStateChange = callback;
|
||||
}
|
||||
|
||||
executeCommand(command: DOMCommand): void {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
// For async commands that need to be executed manually
|
||||
addToUndoStack(command: DOMCommand): void {
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
undo(): boolean {
|
||||
const command = this.undoStack.pop();
|
||||
if (command) {
|
||||
command.undo();
|
||||
this.redoStack.push(command);
|
||||
this.onStateChange?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const command = this.redoStack.pop();
|
||||
if (command) {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
this.onStateChange?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Shared constants for PageEditor grid layout
|
||||
export const GRID_CONSTANTS = {
|
||||
ITEM_WIDTH: '20rem', // page width
|
||||
ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap
|
||||
ITEM_GAP: '1.5rem', // gap between items
|
||||
OVERSCAN_SMALL: 4, // Overscan for normal documents
|
||||
OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages)
|
||||
} as const;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFileState } from '../../../contexts/FileContext';
|
||||
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
|
||||
import { FileId } from '../../../types/file';
|
||||
|
||||
export interface PageDocumentHook {
|
||||
document: PDFDocument | null;
|
||||
isVeryLargeDocument: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing PDF document state and metadata in PageEditor
|
||||
* Handles document merging, large document detection, and loading states
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
|
||||
// Stable signature for effects (prevents loops)
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get primary file record outside useMemo to track processedFile changes
|
||||
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
// Compute merged document with stable signature (prevents infinite loops)
|
||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||
if (activeFileIds.length === 0) return 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 (!primaryStirlingFileStub) {
|
||||
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
activeFileIds.length === 1
|
||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||
: activeFileIds
|
||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.join(' + ');
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
||||
const originalFileIds: FileId[] = [];
|
||||
|
||||
activeFileIds.forEach(fileId => {
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record?.insertAfterPageId !== undefined) {
|
||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||
insertionMap.set(record.insertAfterPageId, []);
|
||||
}
|
||||
insertionMap.get(record.insertAfterPageId)!.push(fileId);
|
||||
} else {
|
||||
originalFileIds.push(fileId);
|
||||
}
|
||||
});
|
||||
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[] = [];
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||
// Use fully processed pages with thumbnails
|
||||
filePages = processedFile.pages.map((page, pageIndex) => ({
|
||||
id: `${fileId}-${page.pageNumber}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
thumbnail: page.thumbnail || null,
|
||||
rotation: page.rotation || 0,
|
||||
selected: false,
|
||||
splitAfter: page.splitAfter || false,
|
||||
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
}));
|
||||
} else if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
|
||||
id: `${fileId}-${pageIndex + 1}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
originalPageNumber: pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return filePages;
|
||||
};
|
||||
|
||||
// Collect all pages from original files (without renumbering yet)
|
||||
const originalFilePages: PDFPage[] = [];
|
||||
originalFileIds.forEach(fileId => {
|
||||
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
// Start with all original pages numbered sequentially
|
||||
pages = originalFilePages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
// Process each insertion by finding the page ID and inserting after it
|
||||
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
|
||||
const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId);
|
||||
|
||||
if (targetPageIndex === -1) continue;
|
||||
|
||||
// Collect all pages to insert
|
||||
const allNewPages: PDFPage[] = [];
|
||||
fileIds.forEach(fileId => {
|
||||
const insertedPages = createPagesFromFile(fileId, 1);
|
||||
allNewPages.push(...insertedPages);
|
||||
});
|
||||
|
||||
// Insert all new pages after the target page
|
||||
pages.splice(targetPageIndex + 1, 0, ...allNewPages);
|
||||
|
||||
// Renumber all pages after insertion
|
||||
pages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedDoc: PDFDocument = {
|
||||
id: activeFileIds.join('-'),
|
||||
name,
|
||||
file: primaryFile!,
|
||||
pages,
|
||||
totalPages: pages.length,
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false;
|
||||
}, [mergedPdfDocument?.totalPages]);
|
||||
|
||||
// Loading state
|
||||
const isLoading = globalProcessing && !mergedPdfDocument;
|
||||
|
||||
return {
|
||||
document: mergedPdfDocument,
|
||||
isVeryLargeDocument,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface PageEditorState {
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPageIds: string[];
|
||||
|
||||
// Animation state
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
|
||||
// Split state
|
||||
splitPositions: Set<number>;
|
||||
|
||||
// Export state
|
||||
exportLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setSelectionMode: (mode: boolean) => void;
|
||||
setSelectedPageIds: (pages: string[]) => void;
|
||||
setMovingPage: (pageNumber: number | null) => void;
|
||||
setIsAnimating: (animating: boolean) => void;
|
||||
setSplitPositions: (positions: Set<number>) => void;
|
||||
setExportLoading: (loading: boolean) => void;
|
||||
|
||||
// Helper functions
|
||||
togglePage: (pageId: string) => void;
|
||||
toggleSelectAll: (allPageIds: string[]) => void;
|
||||
animateReorder: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing PageEditor UI state
|
||||
* Handles selection, animation, splits, and export states
|
||||
*/
|
||||
export function usePageEditorState(): PageEditorState {
|
||||
// Selection state
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
|
||||
// Animation state
|
||||
const [movingPage, setMovingPage] = useState<number | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Split state - position-based split tracking (replaces page-based splitAfter)
|
||||
const [splitPositions, setSplitPositions] = useState<Set<number>>(new Set());
|
||||
|
||||
// Export state
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
// Helper functions
|
||||
const togglePage = useCallback((pageId: string) => {
|
||||
setSelectedPageIds(prev =>
|
||||
prev.includes(pageId)
|
||||
? prev.filter(id => id !== pageId)
|
||||
: [...prev, pageId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback((allPageIds: string[]) => {
|
||||
if (!allPageIds.length) return;
|
||||
|
||||
setSelectedPageIds(prev =>
|
||||
prev.length === allPageIds.length ? [] : allPageIds
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animateReorder = useCallback(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => setIsAnimating(false), 500);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
selectionMode,
|
||||
selectedPageIds,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
splitPositions,
|
||||
exportLoading,
|
||||
|
||||
// Setters
|
||||
setSelectionMode,
|
||||
setSelectedPageIds,
|
||||
setMovingPage,
|
||||
setIsAnimating,
|
||||
setSplitPositions,
|
||||
setExportLoading,
|
||||
|
||||
// Helpers
|
||||
togglePage,
|
||||
toggleSelectAll,
|
||||
animateReorder,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
|
||||
|
||||
interface AllToolsNavButtonProps {
|
||||
activeButton: string;
|
||||
@@ -13,6 +15,7 @@ interface AllToolsNavButtonProps {
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
const { getHomeNavigation } = useSidebarNavigation();
|
||||
|
||||
const handleClick = () => {
|
||||
setActiveButton('tools');
|
||||
@@ -24,25 +27,34 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
||||
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
|
||||
|
||||
const navProps = getHomeNavigation();
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, handleClick);
|
||||
};
|
||||
|
||||
const iconNode = (
|
||||
<span className="iconContainer">
|
||||
<AppsIcon sx={{ fontSize: '1.5rem' }} />
|
||||
<AppsIcon sx={{ fontSize: '2rem' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={handleNavClick}
|
||||
size={'lg'}
|
||||
variant="subtle"
|
||||
onClick={handleClick}
|
||||
aria-label={t("quickAccess.allTools", "All Tools")}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
>
|
||||
|
||||
@@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { StirlingFileStub } from "../../types/fileContext";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
record?: FileRecord;
|
||||
record?: StirlingFileStub;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
@@ -25,7 +25,7 @@ interface FileCardProps {
|
||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
||||
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
import FileCard from "./FileCard";
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { StirlingFileStub } from "../../types/fileContext";
|
||||
import { FileId } from "../../types/file";
|
||||
|
||||
interface FileGridProps {
|
||||
files: Array<{ file: File; record?: FileRecord }>;
|
||||
files: Array<{ file: File; record?: StirlingFileStub }>;
|
||||
onRemove?: (index: number) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onSelect?: (fileId: string) => void;
|
||||
selectedFiles?: string[];
|
||||
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onSelect?: (fileId: FileId) => void;
|
||||
selectedFiles?: FileId[];
|
||||
showSearch?: boolean;
|
||||
showSort?: boolean;
|
||||
maxDisplay?: number; // If set, shows only this many files with "Show All" option
|
||||
@@ -119,12 +120,20 @@ const FileGrid = ({
|
||||
direction="row"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
h="30rem"
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles.map((item, idx) => {
|
||||
const fileId = item.record?.id || item.file.name;
|
||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
||||
{displayFiles
|
||||
.filter(item => {
|
||||
if (!item.record?.id) {
|
||||
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const fileId = item.record!.id; // Safe to assert after filter
|
||||
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Image,
|
||||
Badge,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FilePickerModalProps {
|
||||
opened: boolean;
|
||||
@@ -30,7 +31,7 @@ const FilePickerModal = ({
|
||||
onSelectFiles,
|
||||
}: FilePickerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
|
||||
// Reset selection when modal opens
|
||||
useEffect(() => {
|
||||
@@ -39,9 +40,9 @@ const FilePickerModal = ({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const toggleFileSelection = (fileId: string) => {
|
||||
const toggleFileSelection = (fileId: FileId) => {
|
||||
setSelectedFileIds(prev => {
|
||||
return prev.includes(fileId)
|
||||
return prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId];
|
||||
});
|
||||
@@ -56,10 +57,10 @@ const FilePickerModal = ({
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const selectedFiles = storedFiles.filter(f =>
|
||||
const selectedFiles = storedFiles.filter(f =>
|
||||
selectedFileIds.includes(f.id)
|
||||
);
|
||||
|
||||
|
||||
// Convert stored files to File objects
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(async (fileItem) => {
|
||||
@@ -68,12 +69,12 @@ const FilePickerModal = ({
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
|
||||
// If it has a file property, use that
|
||||
if (fileItem.file && fileItem.file instanceof File) {
|
||||
return fileItem.file;
|
||||
}
|
||||
|
||||
|
||||
// If it's from IndexedDB storage, reconstruct the File
|
||||
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
|
||||
const arrayBuffer = await fileItem.arrayBuffer();
|
||||
@@ -83,8 +84,8 @@ const FilePickerModal = ({
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// If it has data property, reconstruct the File
|
||||
|
||||
// If it has data property, reconstruct the File
|
||||
if (fileItem.data) {
|
||||
const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' });
|
||||
return new File([blob], fileItem.name, {
|
||||
@@ -92,7 +93,7 @@ const FilePickerModal = ({
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
console.warn('Could not convert file item:', fileItem);
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -101,10 +102,10 @@ const FilePickerModal = ({
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter out any null values and return valid Files
|
||||
const validFiles = convertedFiles.filter((f): f is File => f !== null);
|
||||
|
||||
|
||||
onSelectFiles(validFiles);
|
||||
onClose();
|
||||
};
|
||||
@@ -156,18 +157,18 @@ const FilePickerModal = ({
|
||||
{storedFiles.map((file) => {
|
||||
const fileId = file.id;
|
||||
const isSelected = selectedFileIds.includes(fileId);
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={fileId}
|
||||
p="sm"
|
||||
style={{
|
||||
border: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
border: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
@@ -180,7 +181,7 @@ const FilePickerModal = ({
|
||||
onChange={() => toggleFileSelection(fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
|
||||
{/* Thumbnail */}
|
||||
<Box
|
||||
style={{
|
||||
@@ -246,11 +247,11 @@ const FilePickerModal = ({
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t("close", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFileIds.length === 0}
|
||||
>
|
||||
{selectedFileIds.length > 0
|
||||
{selectedFileIds.length > 0
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
|
||||
: t("fileUpload.loadFromStorage", "Load Files")
|
||||
}
|
||||
@@ -261,4 +262,4 @@ const FilePickerModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerModal;
|
||||
export default FilePickerModal;
|
||||
|
||||
@@ -32,9 +32,10 @@ const FileUploadButton = ({
|
||||
onChange={onChange}
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} variant={variant} fullWidth={fullWidth}>
|
||||
<Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
|
||||
{file ? file.name : (placeholder || defaultPlaceholder)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '../../hooks/useCookieConsent';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
termsAndConditions?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
analyticsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
privacyPolicy = '/privacy',
|
||||
termsAndConditions = '/terms',
|
||||
accessibilityStatement = 'accessibility',
|
||||
analyticsEnabled = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: 'var(--footer-height)',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Flex gap="md"
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="row"
|
||||
style={{ fontSize: '0.75rem' }}>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
|
||||
>
|
||||
{t('survey.nav', 'Survey')}
|
||||
</a>
|
||||
{privacyPolicy && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={privacyPolicy}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
{termsAndConditions && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={termsAndConditions}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
)}
|
||||
{accessibilityStatement && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={accessibilityStatement}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
)}
|
||||
{analyticsEnabled && (
|
||||
<button
|
||||
className="footer-link px-3"
|
||||
id="cookieBanner"
|
||||
onClick={showCookiePreferences}
|
||||
>
|
||||
{t('legal.showCookieBanner', 'Cookie Preferences')}
|
||||
</button>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
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 AddIcon from '@mui/icons-material/Add';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addMultipleFiles } = useFileHandler();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addMultipleFiles(files);
|
||||
};
|
||||
|
||||
const handleAddFilesClick = () => {
|
||||
const handleOpenFilesModal = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
@@ -29,22 +36,22 @@ const LandingPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
||||
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95vh]"
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bottom: 0,
|
||||
borderRadius: '0.5rem 0.5rem 0 0',
|
||||
borderRadius: '0.25rem 0.25rem 0 0',
|
||||
filter: 'var(--drop-shadow-filter)',
|
||||
backgroundColor: 'var(--landing-paper-bg)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
transition: 'background-color 0.4s ease',
|
||||
}}
|
||||
activateOnClick={false}
|
||||
styles={{
|
||||
@@ -59,7 +66,7 @@ const LandingPage = () => {
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: ".5rem",
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
|
||||
}}
|
||||
@@ -68,15 +75,13 @@ const LandingPage = () => {
|
||||
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
width: '10rem',
|
||||
height: 'auto',
|
||||
pointerEvents: 'none',
|
||||
marginTop: '-0.5rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`min-h-[25vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[360px] border transition-all duration-200 dropzone-inner relative`}
|
||||
className={`min-h-[45vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[30rem] max-w-[calc(100%-2rem)] border transition-all duration-200 dropzone-inner relative`}
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: 'var(--landing-inner-paper-bg)',
|
||||
@@ -99,26 +104,73 @@ const LandingPage = () => {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files Button */}
|
||||
<Button
|
||||
{/* Add Files + Native Upload Buttons */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '80%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem',
|
||||
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onClick={handleAddFilesClick}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<AddIcon className="text-[var(--accent-interactive)]" />
|
||||
<span>
|
||||
{t('fileUpload.uploadFiles', 'Upload Files')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for native file picker */}
|
||||
<input
|
||||
@@ -137,7 +189,7 @@ const LandingPage = () => {
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click to upload')}
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</Dropzone>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea } from '@mantine/core';
|
||||
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
interface LanguageSelectorProps {
|
||||
position?: React.ComponentProps<typeof Menu>['position'];
|
||||
offset?: number;
|
||||
compact?: boolean; // icon-only trigger
|
||||
}
|
||||
|
||||
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
|
||||
|
||||
@@ -21,33 +26,32 @@ const LanguageSelector = () => {
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||
// Create ripple effect at click position
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
|
||||
// Create ripple effect at click position (only for button mode)
|
||||
if (!compact) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
}
|
||||
|
||||
// Start transition animation
|
||||
setIsChanging(true);
|
||||
setPendingLanguage(value);
|
||||
|
||||
|
||||
// Simulate processing time for smooth transition
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(value);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setIsChanging(false);
|
||||
setPendingLanguage(null);
|
||||
setOpened(false);
|
||||
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
}, 300);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
@@ -64,28 +68,18 @@ const LanguageSelector = () => {
|
||||
<style>
|
||||
{`
|
||||
@keyframes ripple-expand {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
0% { width: 0; height: 0; opacity: 0.6; }
|
||||
50% { opacity: 0.3; }
|
||||
100% { width: 100px; height: 100px; opacity: 0; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Menu
|
||||
opened={opened}
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position="bottom-start"
|
||||
offset={8}
|
||||
position={position}
|
||||
offset={offset}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
@@ -93,29 +87,45 @@ const LanguageSelector = () => {
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
{compact ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
title={currentLanguage}
|
||||
className="right-rail-icon"
|
||||
styles={{
|
||||
root: {
|
||||
color: 'var(--right-rail-icon)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
}
|
||||
},
|
||||
label: { fontSize: '12px', fontWeight: 500 }
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
@@ -153,15 +163,15 @@ const LanguageSelector = () => {
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: option.value === i18n.language
|
||||
backgroundColor: option.value === i18n.language
|
||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||
: 'transparent',
|
||||
color: option.value === i18n.language
|
||||
color: option.value === i18n.language
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: option.value === i18n.language
|
||||
backgroundColor: option.value === i18n.language
|
||||
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
||||
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
transform: 'translateY(-1px)',
|
||||
@@ -181,9 +191,7 @@ const LanguageSelector = () => {
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
{/* Ripple effect */}
|
||||
{rippleEffect && pendingLanguage === option.value && (
|
||||
{!compact && rippleEffect && pendingLanguage === option.value && (
|
||||
<div
|
||||
key={rippleEffect.key}
|
||||
style={{
|
||||
@@ -212,4 +220,4 @@ const LanguageSelector = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
export default LanguageSelector;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { addCollection, Icon } from '@iconify/react';
|
||||
import iconSet from '../../assets/material-symbols-icons.json';
|
||||
|
||||
// Load icons synchronously at import time - guaranteed to be ready on first render
|
||||
let iconsLoaded = false;
|
||||
let localIconCount = 0;
|
||||
|
||||
try {
|
||||
if (iconSet) {
|
||||
addCollection(iconSet);
|
||||
iconsLoaded = true;
|
||||
localIconCount = Object.keys(iconSet.icons || {}).length;
|
||||
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
|
||||
}
|
||||
} catch {
|
||||
console.info('ℹ️ Local icons not available - using CDN fallback');
|
||||
}
|
||||
|
||||
interface LocalIconProps {
|
||||
icon: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalIcon component that uses our locally bundled Material Symbols icons
|
||||
* instead of loading from CDN
|
||||
*/
|
||||
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
|
||||
// Convert our icon naming convention to the local collection format
|
||||
const iconName = icon.startsWith('material-symbols:')
|
||||
? icon
|
||||
: `material-symbols:${icon}`;
|
||||
|
||||
// Development logging (only in dev mode)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const logKey = `icon-${iconName}`;
|
||||
if (!sessionStorage.getItem(logKey)) {
|
||||
const source = iconsLoaded ? 'local' : 'CDN';
|
||||
console.debug(`🎯 Icon: ${iconName} (${source})`);
|
||||
sessionStorage.setItem(logKey, 'logged');
|
||||
}
|
||||
}
|
||||
|
||||
// Always render the icon - Iconify will use local if available, CDN if not
|
||||
return <Icon icon={iconName} {...props} />;
|
||||
};
|
||||
|
||||
export default LocalIcon;
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
||||
import SettingsIcon from "@mui/icons-material/SettingsRounded";
|
||||
import FolderIcon from "@mui/icons-material/FolderRounded";
|
||||
import LocalIcon from './LocalIcon';
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
|
||||
import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
@@ -19,12 +18,12 @@ import {
|
||||
getActiveNavButton,
|
||||
} from './quickAccessBar/QuickAccessBar';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
}, ref) => {
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -39,12 +38,58 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
// Helper function to render navigation buttons with URL support
|
||||
const renderNavButton = (config: ButtonConfig, index: number) => {
|
||||
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
|
||||
// Check if this button has URL navigation support
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
? getToolNavigation(config.id)
|
||||
: null;
|
||||
|
||||
const handleClick = (e?: React.MouseEvent) => {
|
||||
if (navProps && e) {
|
||||
handleUnlessSpecialClick(e, config.onClick);
|
||||
} else {
|
||||
config.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Render navigation button with conditional URL support
|
||||
return (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<ActionIcon
|
||||
{...(navProps ? {
|
||||
component: "a" as const,
|
||||
href: navProps.href,
|
||||
onClick: (e: React.MouseEvent) => handleClick(e),
|
||||
'aria-label': config.name
|
||||
} : {
|
||||
onClick: () => handleClick()
|
||||
})}
|
||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
||||
variant="subtle"
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const buttonConfigs: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
icon: <MenuBookIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
@@ -54,61 +99,59 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
handleReaderToggle();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sign',
|
||||
name: t("quickAccess.sign", "Sign"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
signature
|
||||
</span>,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('sign');
|
||||
handleToolSelect('sign');
|
||||
}
|
||||
},
|
||||
// TODO: Add sign
|
||||
//{
|
||||
// id: 'sign',
|
||||
// name: t("quickAccess.sign", "Sign"),
|
||||
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// size: 'lg',
|
||||
// isRound: false,
|
||||
// type: 'navigation',
|
||||
// onClick: () => {
|
||||
// setActiveButton('sign');
|
||||
// handleToolSelect('sign');
|
||||
// }
|
||||
//},
|
||||
{
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
automation
|
||||
</span>,
|
||||
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('automate');
|
||||
handleToolSelect('automate');
|
||||
// If already on automate tool, reset it directly
|
||||
if (selectedToolKey === 'automate') {
|
||||
resetTool('automate');
|
||||
} else {
|
||||
handleToolSelect('automate');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
icon: <FolderIcon sx={{ fontSize: "1.25rem" }} />,
|
||||
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: handleFilesButtonClick
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
name: t("quickAccess.activity", "Activity"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
vital_signs
|
||||
</span>,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'navigation',
|
||||
onClick: () => setActiveButton('activity')
|
||||
},
|
||||
//TODO: Activity
|
||||
//{
|
||||
// id: 'activity',
|
||||
// name: t("quickAccess.activity", "Activity"),
|
||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// isRound: true,
|
||||
// size: 'lg',
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
{
|
||||
id: 'config',
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
@@ -157,30 +200,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
<Stack gap="lg" align="center">
|
||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
|
||||
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<ActionIcon
|
||||
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'xl') : '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 2) */}
|
||||
{index === 2 && (
|
||||
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
||||
{index === 1 && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
@@ -194,10 +217,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{buttonConfigs
|
||||
{/* {buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size={config.size || 'lg'}
|
||||
variant="subtle"
|
||||
@@ -214,14 +237,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppConfigModal
|
||||
{/* <AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { ActionIcon, Divider, Popover } from '@mantine/core';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageSelector from '../shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
|
||||
export default function RightRail() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { buttons, actions } = useRightRail();
|
||||
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
|
||||
|
||||
// Access PageEditor functions for page-editor-specific actions
|
||||
const { pageEditorFunctions } = useToolWorkflow();
|
||||
|
||||
// CSV input state for page selection
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
|
||||
// Navigation view
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Compute selection state and total items
|
||||
const getSelectionState = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
const totalItems = activeFiles.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's own state
|
||||
const totalItems = pageEditorFunctions?.totalPages || 0;
|
||||
const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
return { totalItems: 0, selectedCount: 0 };
|
||||
}, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]);
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's select all function
|
||||
pageEditorFunctions?.handleSelectAll?.();
|
||||
}
|
||||
}, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's deselect all function
|
||||
pageEditorFunctions?.handleDeselectAll?.();
|
||||
}
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
} else if (currentView === 'pageEditor') {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
// Close only selected files (do not delete from storage)
|
||||
removeFiles(selectedFileIds, false);
|
||||
|
||||
// Clear selection after closing
|
||||
setSelectedFiles([]);
|
||||
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
// CSV parsing functions for page selection
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
const pageNumbers: number[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i > 0) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(range);
|
||||
if (pageNum > 0) {
|
||||
pageNumbers.push(pageNum);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pageNumbers;
|
||||
}, []);
|
||||
|
||||
const updatePagesFromCSV = useCallback(() => {
|
||||
const rawPages = parseCSVInput(csvInput);
|
||||
// Use PageEditor's total pages for validation
|
||||
const maxPages = pageEditorFunctions?.totalPages || 0;
|
||||
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
|
||||
// Use PageEditor's function to set selected pages
|
||||
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
|
||||
}, [csvInput, parseCSVInput, pageEditorFunctions]);
|
||||
|
||||
// Sync csvInput with PageEditor's selected pages
|
||||
useEffect(() => {
|
||||
const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument
|
||||
? pageEditorFunctions.selectedPageIds.map(id => {
|
||||
const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0).sort((a, b) => a - b)
|
||||
: [];
|
||||
const newCsvInput = sortedPageNumbers.join(', ');
|
||||
setCsvInput(newCsvInput);
|
||||
}, [pageEditorFunctions?.selectedPageIds]);
|
||||
|
||||
// Clear CSV input when files change (use stable signature to avoid ref churn)
|
||||
useEffect(() => {
|
||||
setCsvInput("");
|
||||
}, [filesSignature]);
|
||||
|
||||
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
|
||||
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
|
||||
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
// Mount and show
|
||||
setPageControlsMounted(true);
|
||||
// Next tick to ensure transition applies
|
||||
requestAnimationFrame(() => setPageControlsVisible(true));
|
||||
} else {
|
||||
// Start exit animation
|
||||
setPageControlsVisible(false);
|
||||
// After transition, unmount to remove flex gap
|
||||
const timer = setTimeout(() => setPageControlsMounted(false), 240);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
return (
|
||||
<div className="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && (
|
||||
<>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => actions[btn.id]?.()}
|
||||
disabled={btn.disabled}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Select All Button */}
|
||||
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleSelectAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
|
||||
>
|
||||
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Deselect All Button */}
|
||||
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={currentView === 'viewer' || selectedCount === 0}
|
||||
>
|
||||
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Select by Numbers - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={!pageControlsVisible || totalItems === 0}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: 280 }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={Array.isArray(pageEditorFunctions?.selectedPageIds) ? pageEditorFunctions.selectedPageIds : []}
|
||||
displayDocument={pageEditorFunctions?.displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Export Selected Pages - page editor only */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
||||
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
|
||||
disabled={
|
||||
currentView === 'viewer' ||
|
||||
(currentView === 'fileEditor' && selectedCount === 0) ||
|
||||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
|
||||
{/* Theme toggle and Language dropdown */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
|
||||
<Tooltip content={
|
||||
currentView === 'pageEditor'
|
||||
? t('rightRail.exportAll', 'Export PDF')
|
||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||
} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="right-rail-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import styles from './textInput/TextInput.module.css';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,8 @@ export interface TextInputProps {
|
||||
readOnly?: boolean;
|
||||
/** Accessibility label */
|
||||
'aria-label'?: string;
|
||||
/** Focus event handler */
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
@@ -45,6 +48,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
'aria-label': ariaLabel,
|
||||
onFocus,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
@@ -62,7 +66,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
return (
|
||||
<div className={`${styles.container} ${className}`} style={style}>
|
||||
{icon && (
|
||||
<span
|
||||
<span
|
||||
className={styles.icon}
|
||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||
>
|
||||
@@ -80,6 +84,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
aria-label={ariaLabel}
|
||||
onFocus={onFocus}
|
||||
style={{
|
||||
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
|
||||
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
|
||||
@@ -96,7 +101,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<span className="material-symbols-rounded">close</span>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
|
||||
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
|
||||
import { TooltipTip } from '../../types/tips';
|
||||
@@ -124,8 +125,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
if (sidebarTooltip) return null;
|
||||
|
||||
switch (position) {
|
||||
case 'top': return "tooltip-arrow tooltip-arrow-top";
|
||||
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
|
||||
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
|
||||
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
|
||||
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
||||
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
||||
default: return "tooltip-arrow tooltip-arrow-right";
|
||||
@@ -150,7 +151,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
width: (maxWidth !== undefined ? maxWidth : '25rem'),
|
||||
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
|
||||
minWidth: minWidth,
|
||||
zIndex: 9999,
|
||||
visibility: positionReady ? 'visible' : 'hidden',
|
||||
@@ -171,9 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}}
|
||||
title="Close tooltip"
|
||||
>
|
||||
<span className="material-symbols-rounded">
|
||||
close
|
||||
</span>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
)}
|
||||
{arrow && getArrowClass() && (
|
||||
|
||||
@@ -1,27 +1,77 @@
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { Button, SegmentedControl, Loader } from "@mantine/core";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { Group } from "@mantine/core";
|
||||
import { ModeType } from '../../contexts/NavigationContext';
|
||||
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
// Stable view option objects that don't recreate on every render
|
||||
const VIEW_OPTIONS_BASE = [
|
||||
{ value: "viewer", icon: VisibilityIcon },
|
||||
{ value: "pageEditor", icon: EditNoteIcon },
|
||||
{ value: "fileEditor", icon: FolderIcon },
|
||||
] as const;
|
||||
const viewOptionStyle = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
}
|
||||
|
||||
|
||||
// Build view options showing text only for current view; others icon-only with tooltip
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
|
||||
{
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<span>Viewer</span>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Tooltip content="Page Editor" position="bottom" arrow={true}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
) : (
|
||||
switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
value: "pageEditor",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Tooltip content="Active Files" position="bottom" arrow={true}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
value: "fileEditor",
|
||||
},
|
||||
];
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: ModeType;
|
||||
setCurrentView: (view: ModeType) => void;
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
@@ -30,90 +80,64 @@ const TopControls = ({
|
||||
setCurrentView,
|
||||
selectedToolKey,
|
||||
}: TopControlsProps) => {
|
||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
// Guard against redundant changes
|
||||
if (view === currentView) return;
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workbench = view;
|
||||
|
||||
// Show immediate feedback
|
||||
setSwitchingTo(view);
|
||||
setSwitchingTo(workbench);
|
||||
|
||||
// Defer the heavy view change to next frame so spinner can render
|
||||
requestAnimationFrame(() => {
|
||||
// Give the spinner one more frame to show
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentView(view as ModeType);
|
||||
|
||||
setCurrentView(workbench);
|
||||
|
||||
// Clear the loading state after view change completes
|
||||
setTimeout(() => setSwitchingTo(null), 300);
|
||||
});
|
||||
});
|
||||
}, [setCurrentView, currentView]);
|
||||
|
||||
// Memoize the SegmentedControl data with stable references
|
||||
const viewOptions = useMemo(() =>
|
||||
VIEW_OPTIONS_BASE.map(option => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<Group gap={option.value === "viewer" ? 5 : 4}>
|
||||
{switchingTo === option.value ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<option.icon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
})), [switchingTo]);
|
||||
|
||||
const getThemeIcon = () => {
|
||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||
if (themeMode === "dark") return <LightModeIcon />;
|
||||
return <DarkModeIcon />;
|
||||
};
|
||||
}, [setCurrentView]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
|
||||
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
|
||||
}`}>
|
||||
<Button
|
||||
onClick={toggleTheme}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label="Toggle theme"
|
||||
disabled={isToggleDisabled}
|
||||
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
|
||||
title={
|
||||
isToggleDisabled
|
||||
? "Button disabled for 3 seconds..."
|
||||
: isRainbowMode
|
||||
? "Rainbow Mode Active! Click to exit"
|
||||
: "Toggle theme (click rapidly 6 times for a surprise!)"
|
||||
}
|
||||
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
{!isToolSelected && (
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data={viewOptions}
|
||||
data={createViewOptions(currentView, switchingTo)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
style={{
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: switchingTo ? 0.8 : 1,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2.6rem',
|
||||
},
|
||||
control: {
|
||||
borderRadius: 9999,
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2rem',
|
||||
},
|
||||
label: {
|
||||
paddingTop: '0rem',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
try { ro.disconnect(); } catch {}
|
||||
try { mo.disconnect(); } catch {}
|
||||
try { ro.disconnect(); } catch { /* Ignore errors */ }
|
||||
try { mo.disconnect(); } catch { /* Ignore errors */ }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||
*
|
||||
*
|
||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||
* tool is currently active and offers a back button to return to the All Tools list.
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - Shows tool icon and name when a tool is selected
|
||||
* - Hover to reveal back arrow for returning to All Tools
|
||||
@@ -16,6 +16,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
||||
import { useSidebarNavigation } from '../../../hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '../../../utils/clickHandlers';
|
||||
import FitText from '../FitText';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
@@ -26,8 +28,9 @@ interface ActiveToolButtonProps {
|
||||
|
||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
||||
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton }) => {
|
||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||
const { getHomeNavigation } = useSidebarNavigation();
|
||||
|
||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||
const indicatorShouldShow = Boolean(
|
||||
@@ -38,7 +41,6 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
|
||||
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
|
||||
const [replayAnim, setReplayAnim] = useState<boolean>(false);
|
||||
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
||||
const [isBackHover, setIsBackHover] = useState<boolean>(false);
|
||||
const prevKeyRef = useRef<string | null>(null);
|
||||
const collapseTimeoutRef = useRef<number | null>(null);
|
||||
@@ -71,11 +73,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
replayRafRef.current = requestAnimationFrame(() => {
|
||||
setReplayAnim(true);
|
||||
});
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setReplayAnim(false);
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
@@ -84,10 +84,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
@@ -95,11 +93,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
const triggerCollapse = () => {
|
||||
clearTimers();
|
||||
setIndicatorVisible(false);
|
||||
setIsAnimating(true);
|
||||
collapseTimeoutRef.current = window.setTimeout(() => {
|
||||
setIndicatorTool(null);
|
||||
prevKeyRef.current = null;
|
||||
setIsAnimating(false);
|
||||
collapseTimeoutRef.current = null;
|
||||
}, 500); // match CSS transition duration
|
||||
}
|
||||
@@ -142,21 +138,26 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={getHomeNavigation().href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, () => {
|
||||
setActiveButton('tools');
|
||||
handleBackToTools();
|
||||
});
|
||||
}}
|
||||
size={'xl'}
|
||||
variant="subtle"
|
||||
onMouseEnter={() => setIsBackHover(true)}
|
||||
onMouseLeave={() => setIsBackHover(false)}
|
||||
onClick={() => {
|
||||
setActiveButton('tools');
|
||||
handleBackToTools();
|
||||
}}
|
||||
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
||||
style={{
|
||||
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
|
||||
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# RightRail Component
|
||||
|
||||
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Top Section**: Dynamic buttons from tools (empty when none)
|
||||
- **Middle Section**: Grid, Cut, Undo, Redo
|
||||
- **Bottom Section**: Save, Print, Share
|
||||
|
||||
## Usage
|
||||
|
||||
### For Tools (Recommended)
|
||||
|
||||
```tsx
|
||||
import { useRightRailButtons } from '../hooks/useRightRailButtons';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
function MyTool() {
|
||||
const handleAction = useCallback(() => {
|
||||
// Your action here
|
||||
}, []);
|
||||
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'my-action',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Execute Action',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>My Tool</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'primary',
|
||||
icon: <StarIcon />,
|
||||
tooltip: 'Primary Action',
|
||||
order: 1,
|
||||
onClick: handlePrimary,
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
icon: <SettingsIcon />,
|
||||
tooltip: 'Secondary Action',
|
||||
order: 2,
|
||||
onClick: handleSecondary,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### Conditional Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
// Always show
|
||||
{
|
||||
id: 'process',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Process',
|
||||
disabled: isProcessing,
|
||||
onClick: handleProcess,
|
||||
},
|
||||
// Only show when condition met
|
||||
...(hasResults ? [{
|
||||
id: 'export',
|
||||
icon: <DownloadIcon />,
|
||||
tooltip: 'Export',
|
||||
onClick: handleExport,
|
||||
}] : []),
|
||||
]);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Button Config
|
||||
|
||||
```typescript
|
||||
interface RightRailButtonWithAction {
|
||||
id: string; // Unique identifier
|
||||
icon: React.ReactNode; // Icon component
|
||||
tooltip: string; // Hover tooltip
|
||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||
order?: number; // Sort order (default: 0)
|
||||
disabled?: boolean; // Disabled state (default: false)
|
||||
visible?: boolean; // Visibility (default: true)
|
||||
onClick: () => void; // Click handler
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Features
|
||||
|
||||
- **Undo/Redo**: Automatically integrates with Page Editor
|
||||
- **Theme Support**: Light/dark mode with CSS variables
|
||||
- **Auto Cleanup**: Buttons unregister when tool unmounts
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
|
||||
- Choose appropriate Material-UI icons
|
||||
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
||||
- Use `useCallback` for click handlers to prevent re-registration
|
||||
@@ -0,0 +1,127 @@
|
||||
.right-rail {
|
||||
background-color: var(--right-rail-bg);
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
max-width: 3.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.right-rail-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.right-rail-section {
|
||||
background-color: var(--right-rail-foreground);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.right-rail-divider {
|
||||
width: 2.75rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--tool-subcategory-rule-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.right-rail-icon {
|
||||
color: var(--right-rail-icon);
|
||||
}
|
||||
|
||||
.right-rail-icon[aria-disabled="true"],
|
||||
.right-rail-icon[disabled] {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.right-rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
|
||||
.right-rail-slot {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-enter {
|
||||
animation: rightRailGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-exit {
|
||||
animation: rightRailShrinkUp 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 18rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes rightRailGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 18rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 18rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove bottom margin from close icon */
|
||||
.right-rail-slot .right-rail-icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline appear/disappear animation for page-number selector button */
|
||||
.right-rail-fade {
|
||||
transition-property: opacity, transform, max-height, visibility;
|
||||
transition-duration: 220ms, 220ms, 220ms, 0s;
|
||||
transition-timing-function: ease, ease, ease, linear;
|
||||
transition-delay: 0s, 0s, 0s, 0s;
|
||||
transform-origin: top center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-rail-fade.enter {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
max-height: 3rem;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.right-rail-fade.exit {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
/* delay visibility change so opacity/max-height can finish */
|
||||
transition-delay: 0s, 0s, 0s, 220ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
.tooltip-arrow-top {
|
||||
top: -0.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
transform: translateX(-50%) rotate(-135deg);
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Stack, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||
import ToolButton from './toolPicker/ToolButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolSections } from '../../hooks/useToolSections';
|
||||
import SubcategoryHeader from './shared/SubcategoryHeader';
|
||||
import NoToolsFound from './shared/NoToolsFound';
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
@@ -21,11 +22,12 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p="sm" gap="xs">
|
||||
<Stack p="sm" gap="xs"
|
||||
className="tool-picker-scrollable">
|
||||
{searchGroups.map(group => (
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
|
||||
<Stack gap="xs">
|
||||
<Stack gap="xs">
|
||||
{group.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
@@ -38,12 +40,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
{/* global spacer to allow scrolling past last row in search mode */}
|
||||
{/* Global spacer to allow scrolling past last row in search mode */}
|
||||
<div aria-hidden style={{ height: 200 }} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import ToolPicker from './ToolPicker';
|
||||
@@ -8,11 +6,11 @@ import ToolRenderer from './ToolRenderer';
|
||||
import ToolSearch from './toolPicker/ToolSearch';
|
||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ScrollArea } from '@mantine/core';
|
||||
|
||||
// No props needed - component uses context
|
||||
|
||||
export default function ToolPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef } = sidebarRefs;
|
||||
@@ -26,7 +24,6 @@ export default function ToolPanel() {
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
handleBackToTools
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
|
||||
@@ -40,7 +37,7 @@ export default function ToolPanel() {
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ''
|
||||
}`}
|
||||
style={{
|
||||
width: isPanelVisible ? '20rem' : '0',
|
||||
width: isPanelVisible ? '18.5rem' : '0',
|
||||
padding: '0'
|
||||
}}
|
||||
>
|
||||
@@ -71,17 +68,15 @@ export default function ToolPanel() {
|
||||
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
// Searching view (replaces both picker and content)
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleToolSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
// Tool Picker View
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleToolSelect}
|
||||
@@ -91,15 +86,17 @@ export default function ToolPanel() {
|
||||
</div>
|
||||
) : (
|
||||
// Selected Tool Content View
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Tool content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{selectedToolKey && (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey && (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,19 +25,39 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// On resize adjust headers height to offset height
|
||||
// Keep header heights in sync with any dynamic size changes
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (quickHeaderRef.current) {
|
||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight);
|
||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
|
||||
}
|
||||
if (allHeaderRef.current) {
|
||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight);
|
||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
// Update on window resize
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
|
||||
// Update on element resize (e.g., font load, badge count change, zoom)
|
||||
const observers: ResizeObserver[] = [];
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observe = (el: HTMLDivElement | null, cb: () => void) => {
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => cb());
|
||||
ro.observe(el);
|
||||
observers.push(ro);
|
||||
};
|
||||
observe(quickHeaderRef.current, update);
|
||||
observe(allHeaderRef.current, update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
observers.forEach(o => o.disconnect());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
@@ -71,7 +91,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="100vh"
|
||||
h="100%"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -85,7 +105,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
minHeight: 0,
|
||||
height: "100%"
|
||||
height: "100%",
|
||||
marginTop: -2
|
||||
}}
|
||||
className="tool-picker-scrollable"
|
||||
>
|
||||
@@ -109,19 +130,18 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
marginBottom: -1,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
fontWeight: 600,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
onClick={() => scrollTo(quickAccessRef)}
|
||||
>
|
||||
<span>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--tool-header-badge-bg)",
|
||||
@@ -136,7 +156,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Box ref={quickAccessRef} w="100%">
|
||||
<Box ref={quickAccessRef} w="100%" my="sm">
|
||||
<Stack p="sm" gap="xs">
|
||||
{quickSection?.subcategories.map(sc =>
|
||||
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
|
||||
@@ -152,12 +172,12 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
ref={allHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: quickSection ? quickHeaderHeight - 1: 0,
|
||||
top: quickSection ? quickHeaderHeight -1 : 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
fontWeight: 600,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
@@ -167,7 +187,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
}}
|
||||
onClick={() => scrollTo(allToolsRef)}
|
||||
>
|
||||
<span>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--tool-header-badge-bg)",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AddPasswordSettings from './AddPasswordSettings';
|
||||
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
|
||||
import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
|
||||
|
||||
// Mock useTranslation with predictable return values
|
||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
|
||||
import { Stack, PasswordInput, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface WatermarkTypeSettingsProps {
|
||||
@@ -16,7 +15,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={watermarkType === 'text' ? 'filled' : 'outline'}
|
||||
color={watermarkType === 'text' ? 'blue' : 'gray'}
|
||||
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onWatermarkTypeChange('text')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
@@ -27,7 +26,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
|
||||
</Button>
|
||||
<Button
|
||||
variant={watermarkType === 'image' ? 'filled' : 'outline'}
|
||||
color={watermarkType === 'image' ? 'blue' : 'gray'}
|
||||
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onWatermarkTypeChange('image')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
import { removeEmojis } from "../../../utils/textUtils";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
Stack,
|
||||
Group,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Divider,
|
||||
Modal
|
||||
} from '@mantine/core';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import { ToolRegistry } from '../../../data/toolsTaxonomy';
|
||||
import ToolConfigurationModal from './ToolConfigurationModal';
|
||||
import ToolList from './ToolList';
|
||||
import IconSelector from './IconSelector';
|
||||
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
|
||||
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
|
||||
|
||||
@@ -22,7 +24,7 @@ interface AutomationCreationProps {
|
||||
existingAutomation?: AutomationConfig;
|
||||
onBack: () => void;
|
||||
onComplete: (automation: AutomationConfig) => void;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
||||
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
|
||||
@@ -31,11 +33,13 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
||||
const {
|
||||
automationName,
|
||||
setAutomationName,
|
||||
automationDescription,
|
||||
setAutomationDescription,
|
||||
automationIcon,
|
||||
setAutomationIcon,
|
||||
selectedTools,
|
||||
addTool,
|
||||
removeTool,
|
||||
updateTool,
|
||||
hasUnsavedChanges,
|
||||
canSaveAutomation,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
@@ -78,14 +82,6 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
||||
updateTool(selectedTools.length, newTool);
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setUnsavedWarningOpen(true);
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBack = () => {
|
||||
setUnsavedWarningOpen(false);
|
||||
onBack();
|
||||
@@ -98,9 +94,10 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
||||
const saveAutomation = async () => {
|
||||
if (!canSaveAutomation()) return;
|
||||
|
||||
const automation = {
|
||||
const automationData = {
|
||||
name: automationName.trim(),
|
||||
description: '',
|
||||
description: automationDescription.trim(),
|
||||
icon: automationIcon,
|
||||
operations: selectedTools.map(tool => ({
|
||||
operation: tool.operation,
|
||||
parameters: tool.parameters || {}
|
||||
@@ -109,7 +106,30 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
||||
|
||||
try {
|
||||
const { automationStorage } = await import('../../../services/automationStorage');
|
||||
const savedAutomation = await automationStorage.saveAutomation(automation);
|
||||
let savedAutomation;
|
||||
|
||||
if (mode === AutomationMode.EDIT && existingAutomation) {
|
||||
// For edit mode, check if name has changed
|
||||
const nameChanged = automationName.trim() !== existingAutomation.name;
|
||||
|
||||
if (nameChanged) {
|
||||
// Name changed - create new automation
|
||||
savedAutomation = await automationStorage.saveAutomation(automationData);
|
||||
} else {
|
||||
// Name unchanged - update existing automation
|
||||
const updatedAutomation = {
|
||||
...existingAutomation,
|
||||
...automationData,
|
||||
id: existingAutomation.id,
|
||||
createdAt: existingAutomation.createdAt
|
||||
};
|
||||
savedAutomation = await automationStorage.updateAutomation(updatedAutomation);
|
||||
}
|
||||
} else {
|
||||
// Create mode - always create new automation
|
||||
savedAutomation = await automationStorage.saveAutomation(automationData);
|
||||
}
|
||||
|
||||
onComplete(savedAutomation);
|
||||
} catch (error) {
|
||||
console.error('Error saving automation:', error);
|
||||
@@ -121,17 +141,39 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
|
||||
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
|
||||
{t("automate.creation.intro", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
|
||||
</Text>
|
||||
<Divider mb="md" />
|
||||
|
||||
<Stack gap="md">
|
||||
{/* Automation Name */}
|
||||
<TextInput
|
||||
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
|
||||
value={automationName}
|
||||
onChange={(e) => setAutomationName(e.currentTarget.value)}
|
||||
{/* Automation Name and Icon */}
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
placeholder={t('automate.creation.name.placeholder', 'My Automation')}
|
||||
value={automationName}
|
||||
withAsterisk
|
||||
label={t('automate.creation.name.label', 'Automation Name')}
|
||||
onChange={(e) => setAutomationName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<IconSelector
|
||||
value={automationIcon || 'SettingsIcon'}
|
||||
onChange={setAutomationIcon}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Automation Description */}
|
||||
<Textarea
|
||||
placeholder={t('automate.creation.description.placeholder', 'Describe what this automation does...')}
|
||||
value={automationDescription}
|
||||
label={t('automate.creation.description.label', 'Description')}
|
||||
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
|
||||
size="sm"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
|
||||
import { Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
|
||||
interface AutomationEntryProps {
|
||||
/** Optional title for the automation (usually for custom ones) */
|
||||
title?: string;
|
||||
/** Optional description for tooltip */
|
||||
description?: string;
|
||||
/** MUI Icon component for the badge */
|
||||
badgeIcon?: React.ComponentType<any>;
|
||||
/** Array of tool operation names in the workflow */
|
||||
@@ -22,17 +27,24 @@ interface AutomationEntryProps {
|
||||
onEdit?: () => void;
|
||||
/** Delete handler */
|
||||
onDelete?: () => void;
|
||||
/** Copy handler (for suggested automations) */
|
||||
onCopy?: () => void;
|
||||
/** Tool registry to resolve operation names */
|
||||
toolRegistry?: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export default function AutomationEntry({
|
||||
title,
|
||||
description,
|
||||
badgeIcon: BadgeIcon,
|
||||
operations,
|
||||
onClick,
|
||||
keepIconColor = false,
|
||||
showMenu = false,
|
||||
onEdit,
|
||||
onDelete
|
||||
onDelete,
|
||||
onCopy,
|
||||
toolRegistry
|
||||
}: AutomationEntryProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -41,6 +53,61 @@ export default function AutomationEntry({
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
// Helper function to resolve tool display names
|
||||
const getToolDisplayName = (operation: string): string => {
|
||||
if (toolRegistry?.[operation]?.name) {
|
||||
return toolRegistry[operation].name;
|
||||
}
|
||||
// Fallback to translation or operation key
|
||||
return t(`${operation}.title`, operation);
|
||||
};
|
||||
|
||||
// Create tooltip content with description and tool chain
|
||||
const createTooltipContent = () => {
|
||||
// Show tooltip if there's a description OR if there are operations to show in the chain
|
||||
if (!description && operations.length === 0) return null;
|
||||
|
||||
const toolChain = operations.map((op, index) => (
|
||||
<React.Fragment key={`${op}-${index}`}>
|
||||
<Text
|
||||
component="span"
|
||||
size="sm"
|
||||
fw={600}
|
||||
style={{
|
||||
color: 'var(--mantine-primary-color-filled)',
|
||||
background: 'var(--mantine-primary-color-light)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{getToolDisplayName(op)}
|
||||
</Text>
|
||||
{index < operations.length - 1 && (
|
||||
<Text component="span" size="sm" mx={4}>
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: '400px', width: 'auto' }}>
|
||||
{description && (
|
||||
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{operations.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
|
||||
{toolChain}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (title) {
|
||||
// Custom automation with title
|
||||
@@ -73,7 +140,7 @@ export default function AutomationEntry({
|
||||
{operations.map((op, index) => (
|
||||
<React.Fragment key={`${op}-${index}`}>
|
||||
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
{t(`${op}.title`, op)}
|
||||
{getToolDisplayName(op)}
|
||||
</Text>
|
||||
|
||||
{index < operations.length - 1 && (
|
||||
@@ -89,7 +156,7 @@ export default function AutomationEntry({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const boxContent = (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
@@ -132,6 +199,17 @@ export default function AutomationEntry({
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onCopy && (
|
||||
<Menu.Item
|
||||
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
{t('automate.copyToSaved', 'Copy to Saved')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Menu.Item
|
||||
leftSection={<EditIcon style={{ fontSize: 16 }} />}
|
||||
@@ -160,4 +238,20 @@ export default function AutomationEntry({
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Show tooltip if there's a description OR operations to display
|
||||
const shouldShowTooltip = description || operations.length > 0;
|
||||
|
||||
return shouldShowTooltip ? (
|
||||
<Tooltip
|
||||
content={createTooltipContent()}
|
||||
position="right"
|
||||
arrow={true}
|
||||
delay={500}
|
||||
>
|
||||
{boxContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
boxContent
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
const cleanup = useResourceCleanup();
|
||||
|
||||
|
||||
// Progress tracking state
|
||||
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||
|
||||
|
||||
// Use the operation hook's loading state
|
||||
const isExecuting = automateOperation?.isLoading || false;
|
||||
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
|
||||
@@ -33,7 +33,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
React.useEffect(() => {
|
||||
if (automation?.operations) {
|
||||
const steps = automation.operations.map((op: any, index: number) => {
|
||||
const tool = toolRegistry[op.operation];
|
||||
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
|
||||
return {
|
||||
id: `${op.operation}-${index}`,
|
||||
operation: op.operation,
|
||||
@@ -74,15 +74,15 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
try {
|
||||
// Use the automateOperation.executeOperation to handle file consumption properly
|
||||
await automateOperation.executeOperation(
|
||||
{
|
||||
{
|
||||
automationConfig: automation,
|
||||
onStepStart: (stepIndex: number, operationName: string) => {
|
||||
onStepStart: (stepIndex: number, _operationName: string) => {
|
||||
setCurrentStepIndex(stepIndex);
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
|
||||
));
|
||||
},
|
||||
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
|
||||
onStepComplete: (stepIndex: number, _resultFiles: File[]) => {
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
|
||||
));
|
||||
@@ -95,7 +95,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
},
|
||||
selectedFiles
|
||||
);
|
||||
|
||||
|
||||
// Mark all as completed and reset current step
|
||||
setCurrentStepIndex(-1);
|
||||
console.log(`✅ Automation completed successfully`);
|
||||
@@ -118,20 +118,20 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
case EXECUTION_STATUS.ERROR:
|
||||
return <span style={{ fontSize: 16, color: 'red' }}>✕</span>;
|
||||
case EXECUTION_STATUS.RUNNING:
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderTop: '2px solid #007bff',
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderTop: '2px solid #007bff',
|
||||
borderRadius: '50%',
|
||||
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
|
||||
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
|
||||
}} />;
|
||||
default:
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderRadius: '50%'
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderRadius: '50%'
|
||||
}} />;
|
||||
}
|
||||
};
|
||||
@@ -170,8 +170,8 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
{getStepIcon(step)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text
|
||||
size="sm"
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
|
||||
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
|
||||
@@ -220,4 +220,4 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AutomationEntry from "./AutomationEntry";
|
||||
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
|
||||
import { AutomationConfig } from "../../../types/automation";
|
||||
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
|
||||
import { iconMap } from './iconMap';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
|
||||
interface AutomationSelectionProps {
|
||||
savedAutomations: AutomationConfig[];
|
||||
@@ -13,6 +15,8 @@ interface AutomationSelectionProps {
|
||||
onRun: (automation: AutomationConfig) => void;
|
||||
onEdit: (automation: AutomationConfig) => void;
|
||||
onDelete: (automation: AutomationConfig) => void;
|
||||
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export default function AutomationSelection({
|
||||
@@ -20,7 +24,9 @@ export default function AutomationSelection({
|
||||
onCreateNew,
|
||||
onRun,
|
||||
onEdit,
|
||||
onDelete
|
||||
onDelete,
|
||||
onCopyFromSuggested,
|
||||
toolRegistry
|
||||
}: AutomationSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const suggestedAutomations = useSuggestedAutomations();
|
||||
@@ -38,20 +44,26 @@ export default function AutomationSelection({
|
||||
operations={[]}
|
||||
onClick={onCreateNew}
|
||||
keepIconColor={true}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
{/* Saved Automations */}
|
||||
{savedAutomations.map((automation) => (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
title={automation.name}
|
||||
badgeIcon={SettingsIcon}
|
||||
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
showMenu={true}
|
||||
onEdit={() => onEdit(automation)}
|
||||
onDelete={() => onDelete(automation)}
|
||||
/>
|
||||
))}
|
||||
{savedAutomations.map((automation) => {
|
||||
const IconComponent = automation.icon ? iconMap[automation.icon as keyof typeof iconMap] : SettingsIcon;
|
||||
return (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
title={automation.name}
|
||||
description={automation.description}
|
||||
badgeIcon={IconComponent || SettingsIcon}
|
||||
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
showMenu={true}
|
||||
onEdit={() => onEdit(automation)}
|
||||
onDelete={() => onDelete(automation)}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Divider pb='sm' />
|
||||
|
||||
{/* Suggested Automations */}
|
||||
@@ -63,9 +75,14 @@ export default function AutomationSelection({
|
||||
{suggestedAutomations.map((automation) => (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
title={automation.name}
|
||||
description={automation.description}
|
||||
badgeIcon={automation.icon}
|
||||
operations={automation.operations.map(op => op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
showMenu={true}
|
||||
onCopy={() => onCopyFromSuggested(automation)}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Text, Stack, Button, SimpleGrid, Tooltip, Popover } from "@mantine/core";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { iconMap, iconOptions } from './iconMap';
|
||||
|
||||
interface IconSelectorProps {
|
||||
value?: string;
|
||||
onChange?: (iconKey: string) => void;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export default function IconSelector({ value = "SettingsIcon", onChange, size = "sm" }: IconSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const selectedIconComponent = iconMap[value as keyof typeof iconMap] || iconMap.SettingsIcon;
|
||||
|
||||
const handleIconSelect = (iconKey: string) => {
|
||||
onChange?.(iconKey);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
const iconSize = size === "sm" ? 16 : size === "md" ? 18 : 20;
|
||||
|
||||
return (
|
||||
<Stack gap="1px">
|
||||
<Text size="sm" fw={600} style={{ color: "var(--mantine-color-primary)" }}>
|
||||
{t("automate.creation.icon.label", "Icon")}
|
||||
</Text>
|
||||
|
||||
<Popover
|
||||
opened={isDropdownOpen}
|
||||
onClose={() => setIsDropdownOpen(false)}
|
||||
onDismiss={() => setIsDropdownOpen(false)}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
style={{
|
||||
width: size === "sm" ? "3.75rem" : "4.375rem",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
paddingLeft: "0.5rem",
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
color: "var(--mantine-color-text)",
|
||||
|
||||
}}
|
||||
>
|
||||
{React.createElement(selectedIconComponent, { style: { fontSize: iconSize } })}
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
fontSize: iconSize * 0.8,
|
||||
position: "absolute",
|
||||
right: "0.25rem",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={4} spacing="xs">
|
||||
{iconOptions.map((option) => {
|
||||
const IconComponent = iconMap[option.value as keyof typeof iconMap];
|
||||
const isSelected = value === option.value;
|
||||
|
||||
return (
|
||||
<Tooltip key={option.value} label={option.label}>
|
||||
<Box
|
||||
onClick={() => handleIconSelect(option.value)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSelected ? "var(--mantine-color-gray-1)" : "transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconComponent
|
||||
style={{
|
||||
fontSize: iconSize,
|
||||
color: isSelected ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
@@ -32,10 +32,9 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parameters, setParameters] = useState<any>({});
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation];
|
||||
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
|
||||
const SettingsComponent = toolInfo?.settingsComponent;
|
||||
|
||||
// Initialize parameters from tool (which should contain defaults from registry)
|
||||
@@ -87,9 +86,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isValid) {
|
||||
onSave(parameters);
|
||||
}
|
||||
onSave(parameters);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -127,7 +124,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t('automate.config.save', 'Save Configuration')}
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, Stack, Group, ActionIcon } from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AddCircleOutline from '@mui/icons-material/AddCircleOutline';
|
||||
import { AutomationTool } from '../../../types/automation';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import ToolSelector from './ToolSelector';
|
||||
import AutomationEntry from './AutomationEntry';
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, Stack, Group, ActionIcon } from "@mantine/core";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
|
||||
import { AutomationTool } from "../../../types/automation";
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import ToolSelector from "./ToolSelector";
|
||||
import AutomationEntry from "./AutomationEntry";
|
||||
|
||||
interface ToolListProps {
|
||||
tools: AutomationTool[];
|
||||
@@ -29,62 +28,68 @@ export default function ToolList({
|
||||
onToolConfigure,
|
||||
onToolAdd,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
getToolDefaultParameters,
|
||||
}: ToolListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleToolSelect = (index: number, newOperation: string) => {
|
||||
const defaultParams = getToolDefaultParameters(newOperation);
|
||||
|
||||
|
||||
onToolUpdate(index, {
|
||||
operation: newOperation,
|
||||
name: getToolName(newOperation),
|
||||
configured: false,
|
||||
parameters: defaultParams
|
||||
parameters: defaultParams,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
|
||||
<Text size="sm" fw={500} mb="xs" style={{ color: "var(--mantine-color-text)" }}>
|
||||
{t("automate.creation.tools.selected", "Selected Tools")} ({tools.length})
|
||||
</Text>
|
||||
<Stack gap="0">
|
||||
{tools.map((tool, index) => (
|
||||
<React.Fragment key={tool.id}>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
position: 'relative',
|
||||
padding: 'var(--mantine-spacing-xs)'
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: tool.operation && !tool.configured
|
||||
? "var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0"
|
||||
: "var(--mantine-radius-lg)",
|
||||
backgroundColor: "var(--mantine-color-gray-2)",
|
||||
position: "relative",
|
||||
padding: "var(--mantine-spacing-xs)",
|
||||
borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px",
|
||||
}}
|
||||
>
|
||||
{/* Delete X in top right */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => onToolRemove(index)}
|
||||
title={t('automate.creation.tools.remove', 'Remove tool')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
zIndex: 1,
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 12 }} />
|
||||
</ActionIcon>
|
||||
{/* Delete X in top right - only show for tools after the first 2 */}
|
||||
{index > 1 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => onToolRemove(index)}
|
||||
title={t("automate.creation.tools.remove", "Remove tool")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
zIndex: 1,
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<div style={{ paddingRight: '1.25rem' }}>
|
||||
<div style={{ paddingRight: "1.25rem" }}>
|
||||
{/* Tool Selection Dropdown with inline settings cog */}
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<ToolSelector
|
||||
key={`tool-selector-${tool.id}`}
|
||||
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
|
||||
excludeTools={['automate']}
|
||||
excludeTools={["automate"]}
|
||||
toolRegistry={toolRegistry}
|
||||
selectedValue={tool.operation}
|
||||
placeholder={tool.name}
|
||||
@@ -97,26 +102,37 @@ export default function ToolList({
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => onToolConfigure(index)}
|
||||
title={t('automate.creation.tools.configure', 'Configure tool')}
|
||||
style={{ color: 'var(--mantine-color-gray-6)' }}
|
||||
title={t("automate.creation.tools.configure", "Configure tool")}
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
>
|
||||
<SettingsIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Configuration status underneath */}
|
||||
{tool.operation && !tool.configured && (
|
||||
<Text pl="md" size="xs" c="dimmed" mt="xs">
|
||||
{t('automate.creation.tools.notConfigured', "! Not Configured")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration status underneath */}
|
||||
{tool.operation && !tool.configured && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderTop: "none",
|
||||
borderRadius: "0 0 var(--mantine-radius-lg) var(--mantine-radius-lg)",
|
||||
backgroundColor: "var(--active-bg)",
|
||||
padding: "var(--mantine-spacing-xs)",
|
||||
}}
|
||||
>
|
||||
<Text pl="md" size="xs" >
|
||||
{t("automate.creation.tools.notConfigured", "! Not Configured")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{index < tools.length - 1 && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Text size="xs" c="dimmed">↓</Text>
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
↓
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -124,19 +140,23 @@ export default function ToolList({
|
||||
|
||||
{/* Arrow before Add Tool Button */}
|
||||
{tools.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Text size="xs" c="dimmed">↓</Text>
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
↓
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Tool Button */}
|
||||
<div style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<AutomationEntry
|
||||
title={t('automate.creation.tools.addTool', 'Add Tool')}
|
||||
title={t("automate.creation.tools.addTool", "Add Tool")}
|
||||
badgeIcon={AddCircleOutline}
|
||||
operations={[]}
|
||||
onClick={onToolAdd}
|
||||
@@ -146,4 +166,4 @@ export default function ToolList({
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
|
||||
import { Stack, Text, ScrollArea } from '@mantine/core';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import { useToolSections } from '../../../hooks/useToolSections';
|
||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||
import ToolSearch from '../toolPicker/ToolSearch';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
|
||||
interface ToolSelectorProps {
|
||||
onSelect: (toolKey: string) => void;
|
||||
@@ -24,6 +25,8 @@ export default function ToolSelector({
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself)
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
@@ -66,13 +69,21 @@ export default function ToolSelector({
|
||||
}
|
||||
|
||||
if (!sections || sections.length === 0) {
|
||||
// If no sections, create a simple group from filtered tools
|
||||
if (baseFilteredTools.length > 0) {
|
||||
return [{
|
||||
name: 'All Tools',
|
||||
subcategoryId: 'all' as any,
|
||||
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the "all" section which contains all tools without duplicates
|
||||
const allSection = sections.find(s => (s as any).key === 'all');
|
||||
return allSection?.subcategories || [];
|
||||
}, [isSearching, searchGroups, sections]);
|
||||
}, [isSearching, searchGroups, sections, baseFilteredTools]);
|
||||
|
||||
const handleToolSelect = useCallback((toolKey: string) => {
|
||||
onSelect(toolKey);
|
||||
@@ -88,8 +99,25 @@ export default function ToolSelector({
|
||||
|
||||
const handleSearchFocus = () => {
|
||||
setOpened(true);
|
||||
setShouldAutoFocus(true); // Request auto-focus for the input
|
||||
};
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setOpened(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
if (opened) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
if (!opened) {
|
||||
@@ -97,6 +125,14 @@ export default function ToolSelector({
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
if (!opened) {
|
||||
setOpened(true);
|
||||
}
|
||||
// Clear auto-focus flag since input is now focused
|
||||
setShouldAutoFocus(false);
|
||||
};
|
||||
|
||||
// Get display value for selected tool
|
||||
const getDisplayValue = () => {
|
||||
if (selectedValue && toolRegistry[selectedValue]) {
|
||||
@@ -106,77 +142,63 @@ export default function ToolSelector({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={(isOpen) => {
|
||||
setOpened(isOpen);
|
||||
// Clear search term when menu closes to show proper display
|
||||
if (!isOpen) {
|
||||
setSearchTerm('');
|
||||
}
|
||||
}}
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
position="bottom-start"
|
||||
offset={4}
|
||||
withinPortal={false}
|
||||
trapFocus={false}
|
||||
shadow="sm"
|
||||
transitionProps={{ duration: 0 }}
|
||||
>
|
||||
<Menu.Target>
|
||||
<div style={{ width: '100%' }}>
|
||||
{selectedValue && toolRegistry[selectedValue] && !opened ? (
|
||||
// Show selected tool in AutomationEntry style when tool is selected and not searching
|
||||
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--mantine-spacing-sm)',
|
||||
padding: '0 0.5rem',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
}}>
|
||||
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
|
||||
{toolRegistry[selectedValue].icon}
|
||||
</div>
|
||||
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
|
||||
{toolRegistry[selectedValue].name}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Show search input when no tool selected or actively searching
|
||||
<ToolSearch
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
toolRegistry={filteredToolRegistry}
|
||||
mode="filter"
|
||||
placeholder={getDisplayValue()}
|
||||
hideIcon={true}
|
||||
onFocus={handleSearchFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<div ref={containerRef} className='rounded-xl'>
|
||||
{/* Always show the target - either selected tool or search input */}
|
||||
|
||||
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
|
||||
<ScrollArea h={350}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{displayGroups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
{isSearching
|
||||
? t('tools.noSearchResults', 'No tools found')
|
||||
: t('tools.noTools', 'No tools available')
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
renderedTools
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{selectedValue && toolRegistry[selectedValue] && !opened ? (
|
||||
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
|
||||
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
|
||||
borderRadius: "var(--mantine-radius-lg)" }}>
|
||||
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
|
||||
onSelect={()=>{}} rounded={true}></ToolButton>
|
||||
</div>
|
||||
) : (
|
||||
// Show search input when no tool selected OR when dropdown is opened
|
||||
<ToolSearch
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
toolRegistry={filteredToolRegistry}
|
||||
mode="unstyled"
|
||||
placeholder={getDisplayValue()}
|
||||
hideIcon={true}
|
||||
onFocus={handleInputFocus}
|
||||
autoFocus={shouldAutoFocus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom dropdown */}
|
||||
{opened && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
boxShadow: 'var(--mantine-shadow-sm)',
|
||||
marginTop: '4px',
|
||||
minWidth: '16rem'
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={350}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{displayGroups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
{isSearching
|
||||
? t('tools.noSearchResults', 'No tools found')
|
||||
: t('tools.noTools', 'No tools available')
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
renderedTools
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
||||
import CropIcon from '@mui/icons-material/Crop';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import WorkIcon from '@mui/icons-material/Work';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
|
||||
export const iconMap = {
|
||||
SettingsIcon,
|
||||
CompressIcon,
|
||||
SwapHorizIcon,
|
||||
CleaningServicesIcon,
|
||||
CropIcon,
|
||||
TextFieldsIcon,
|
||||
PictureAsPdfIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
FolderIcon,
|
||||
CloudIcon,
|
||||
StorageIcon,
|
||||
SearchIcon,
|
||||
DownloadIcon,
|
||||
UploadIcon,
|
||||
PlayArrowIcon,
|
||||
RotateLeftIcon,
|
||||
RotateRightIcon,
|
||||
VisibilityIcon,
|
||||
ContentCutIcon,
|
||||
ContentCopyIcon,
|
||||
WorkIcon,
|
||||
BuildIcon,
|
||||
AutoAwesomeIcon,
|
||||
SmartToyIcon,
|
||||
CheckIcon,
|
||||
SecurityIcon,
|
||||
StarIcon
|
||||
};
|
||||
|
||||
export const iconOptions = [
|
||||
{ value: 'SettingsIcon', label: 'Settings' },
|
||||
{ value: 'CompressIcon', label: 'Compress' },
|
||||
{ value: 'SwapHorizIcon', label: 'Convert' },
|
||||
{ value: 'CleaningServicesIcon', label: 'Clean' },
|
||||
{ value: 'CropIcon', label: 'Crop' },
|
||||
{ value: 'TextFieldsIcon', label: 'Text' },
|
||||
{ value: 'PictureAsPdfIcon', label: 'PDF' },
|
||||
{ value: 'EditIcon', label: 'Edit' },
|
||||
{ value: 'DeleteIcon', label: 'Delete' },
|
||||
{ value: 'FolderIcon', label: 'Folder' },
|
||||
{ value: 'CloudIcon', label: 'Cloud' },
|
||||
{ value: 'StorageIcon', label: 'Storage' },
|
||||
{ value: 'SearchIcon', label: 'Search' },
|
||||
{ value: 'DownloadIcon', label: 'Download' },
|
||||
{ value: 'UploadIcon', label: 'Upload' },
|
||||
{ value: 'PlayArrowIcon', label: 'Play' },
|
||||
{ value: 'RotateLeftIcon', label: 'Rotate Left' },
|
||||
{ value: 'RotateRightIcon', label: 'Rotate Right' },
|
||||
{ value: 'VisibilityIcon', label: 'View' },
|
||||
{ value: 'ContentCutIcon', label: 'Cut' },
|
||||
{ value: 'ContentCopyIcon', label: 'Copy' },
|
||||
{ value: 'WorkIcon', label: 'Work' },
|
||||
{ value: 'BuildIcon', label: 'Build' },
|
||||
{ value: 'AutoAwesomeIcon', label: 'Magic' },
|
||||
{ value: 'SmartToyIcon', label: 'Robot' },
|
||||
{ value: 'CheckIcon', label: 'Check' },
|
||||
{ value: 'SecurityIcon', label: 'Security' },
|
||||
{ value: 'StarIcon', label: 'Star' }
|
||||
];
|
||||
|
||||
export type IconKey = keyof typeof iconMap;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Stack, Text, Checkbox } from "@mantine/core";
|
||||
import { Stack, Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
|
||||
color={parameters.compressionMethod === 'quality' ? 'blue' : 'gray'}
|
||||
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('compressionMethod', 'quality')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
@@ -34,7 +34,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
|
||||
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'gray'}
|
||||
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('compressionMethod', 'filesize')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
|
||||
@@ -15,24 +15,25 @@ import ConvertFromWebSettings from "./ConvertFromWebSettings";
|
||||
import ConvertFromEmailSettings from "./ConvertFromEmailSettings";
|
||||
import ConvertToPdfaSettings from "./ConvertToPdfaSettings";
|
||||
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
|
||||
import {
|
||||
import {
|
||||
FROM_FORMAT_OPTIONS,
|
||||
EXTENSION_TO_ENDPOINT,
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS
|
||||
} from "../../../constants/convertConstants";
|
||||
import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
interface ConvertSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertSettings = ({
|
||||
parameters,
|
||||
const ConvertSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
getAvailableToExtensions,
|
||||
selectedFiles,
|
||||
@@ -52,7 +53,7 @@ const ConvertSettings = ({
|
||||
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
|
||||
return endpointStatus[endpointKey] === true;
|
||||
};
|
||||
|
||||
@@ -61,10 +62,10 @@ const ConvertSettings = ({
|
||||
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
|
||||
// Check if this source format has any available conversions
|
||||
const availableConversions = getAvailableToExtensions(option.value) || [];
|
||||
const hasAvailableConversions = availableConversions.some(targetOption =>
|
||||
const hasAvailableConversions = availableConversions.some(targetOption =>
|
||||
isConversionAvailable(option.value, targetOption.value)
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
...option,
|
||||
enabled: hasAvailableConversions
|
||||
@@ -80,18 +81,18 @@ const ConvertSettings = ({
|
||||
group: 'File',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
|
||||
// Add the dynamic option at the beginning
|
||||
return [dynamicOption, ...baseOptions];
|
||||
}
|
||||
|
||||
|
||||
return baseOptions;
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
return availableOptions.map(option => ({
|
||||
...option,
|
||||
@@ -128,10 +129,10 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
const filterFilesByExtension = (extension: string) => {
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
|
||||
return files.filter(file => {
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
|
||||
|
||||
if (extension === 'any') {
|
||||
return true;
|
||||
} else if (extension === 'image') {
|
||||
@@ -142,21 +143,8 @@ const ConvertSettings = ({
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileSelection = (files: File[]) => {
|
||||
// Map File objects to their actual IDs in FileContext
|
||||
const fileIds = files.map(file => {
|
||||
// Find the file ID by matching file properties
|
||||
const fileRecord = state.files.ids
|
||||
.map(id => selectors.getFileRecord(id))
|
||||
.find(record =>
|
||||
record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified
|
||||
);
|
||||
return fileRecord?.id;
|
||||
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
|
||||
|
||||
const updateFileSelection = (files: StirlingFile[]) => {
|
||||
const fileIds = files.map(file => file.fileId);
|
||||
setSelectedFiles(fileIds);
|
||||
};
|
||||
|
||||
@@ -164,7 +152,7 @@ const ConvertSettings = ({
|
||||
onParameterChange('fromExtension', value);
|
||||
setAutoTargetExtension(value);
|
||||
resetParametersToDefaults();
|
||||
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
const matchingFiles = filterFilesByExtension(value);
|
||||
updateFileSelection(matchingFiles);
|
||||
@@ -232,11 +220,11 @@ const ConvertSettings = ({
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("convert.selectSourceFormatFirst", "Select a source format first")}</Text>
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
@@ -266,9 +254,9 @@ const ConvertSettings = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Color options for image to PDF conversion */}
|
||||
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -281,7 +269,7 @@ const ConvertSettings = ({
|
||||
) : null}
|
||||
|
||||
{/* Web to PDF options */}
|
||||
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -322,4 +310,4 @@ const ConvertSettings = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertSettings;
|
||||
export default ConvertSettings;
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
interface ConvertToPdfaSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
|
||||
import FileUploadButton from "../../shared/FileUploadButton";
|
||||
|
||||
interface CertificateFilesSettingsProps {
|
||||
parameters: ManageSignaturesParameters;
|
||||
onParameterChange: (key: keyof ManageSignaturesParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFilesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Certificate Files based on type */}
|
||||
{parameters.certType === 'PEM' && (
|
||||
<Stack gap="sm">
|
||||
<FileUploadButton
|
||||
file={parameters.privateKeyFile}
|
||||
onChange={(file) => onParameterChange('privateKeyFile', file || undefined)}
|
||||
accept=".pem,.der"
|
||||
disabled={disabled}
|
||||
placeholder={t('manageSignatures.signing.choosePrivateKey', 'Choose Private Key File')}
|
||||
/>
|
||||
{parameters.privateKeyFile && (
|
||||
<FileUploadButton
|
||||
file={parameters.certFile}
|
||||
onChange={(file) => onParameterChange('certFile', file || undefined)}
|
||||
accept=".pem,.der"
|
||||
disabled={disabled}
|
||||
placeholder={t('manageSignatures.signing.chooseCertificate', 'Choose Certificate File')}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.certType === 'PKCS12' && (
|
||||
<FileUploadButton
|
||||
file={parameters.p12File}
|
||||
onChange={(file) => onParameterChange('p12File', file || undefined)}
|
||||
accept=".p12,.pfx"
|
||||
disabled={disabled}
|
||||
placeholder={t('manageSignatures.signing.chooseP12File', 'Choose PKCS12 File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameters.certType === 'JKS' && (
|
||||
<FileUploadButton
|
||||
file={parameters.jksFile}
|
||||
onChange={(file) => onParameterChange('jksFile', file || undefined)}
|
||||
accept=".jks,.keystore"
|
||||
disabled={disabled}
|
||||
placeholder={t('manageSignatures.signing.chooseJksFile', 'Choose JKS File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password - only show when files are uploaded */}
|
||||
{parameters.certType && (
|
||||
(parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) ||
|
||||
(parameters.certType === 'PKCS12' && parameters.p12File) ||
|
||||
(parameters.certType === 'JKS' && parameters.jksFile)
|
||||
) && (
|
||||
<TextInput
|
||||
label={t('manageSignatures.signing.password', 'Certificate Password')}
|
||||
placeholder={t('manageSignatures.signing.passwordOptional', 'Leave empty if no password')}
|
||||
type="password"
|
||||
value={parameters.password}
|
||||
onChange={(event) => onParameterChange('password', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateFilesSettings;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
|
||||
|
||||
interface CertificateTypeSettingsProps {
|
||||
parameters: ManageSignaturesParameters;
|
||||
onParameterChange: (key: keyof ManageSignaturesParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = false }: CertificateTypeSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Certificate Type Selection */}
|
||||
<Stack gap="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.certType === 'PKCS12' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PKCS12' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PKCS12')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
PKCS#12
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.certType === 'PEM' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PEM' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PEM')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
PEM
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant={parameters.certType === 'JKS' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'JKS' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'JKS')}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
JKS
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateTypeSettings;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Stack, Text, Button, TextInput, NumberInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
|
||||
|
||||
interface SignatureAppearanceSettingsProps {
|
||||
parameters: ManageSignaturesParameters;
|
||||
onParameterChange: (key: keyof ManageSignaturesParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SignatureAppearanceSettings = ({ parameters, onParameterChange, disabled = false }: SignatureAppearanceSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Signature Visibility */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('manageSignatures.appearance.title', 'Signature Appearance')}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={!parameters.showSignature ? 'filled' : 'outline'}
|
||||
color={!parameters.showSignature ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showSignature', false)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('manageSignatures.appearance.invisible', 'Invisible')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.showSignature ? 'filled' : 'outline'}
|
||||
color={parameters.showSignature ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showSignature', true)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('manageSignatures.appearance.visible', 'Visible')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Visible Signature Options */}
|
||||
{parameters.showSignature && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('manageSignatures.appearance.options.title', 'Signature Details')}
|
||||
</Text>
|
||||
<TextInput
|
||||
label={t('manageSignatures.signing.reason', 'Reason for Signing')}
|
||||
value={parameters.reason}
|
||||
onChange={(event) => onParameterChange('reason', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('manageSignatures.signing.location', 'Location')}
|
||||
value={parameters.location}
|
||||
onChange={(event) => onParameterChange('location', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('manageSignatures.signing.name', 'Signer Name')}
|
||||
value={parameters.name}
|
||||
onChange={(event) => onParameterChange('name', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('manageSignatures.signing.pageNumber', 'Page Number')}
|
||||
value={parameters.pageNumber}
|
||||
onChange={(value) => onParameterChange('pageNumber', value || 1)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('manageSignatures.signing.logoTitle', 'Logo')}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={!parameters.showLogo ? 'filled' : 'outline'}
|
||||
color={!parameters.showLogo ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showLogo', false)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('manageSignatures.signing.noLogo', 'No Logo')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.showLogo ? 'filled' : 'outline'}
|
||||
color={parameters.showLogo ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showLogo', true)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('manageSignatures.signing.showLogo', 'Show Logo')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureAppearanceSettings;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Stack, Select, Text, Divider } from '@mantine/core';
|
||||
import { Stack, Select, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
||||
|
||||
+2
-6
@@ -8,11 +8,7 @@ interface RemoveCertificateSignSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange, // Unused - kept for interface consistency and future extensibility
|
||||
disabled = false
|
||||
}) => {
|
||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -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 { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@ interface RepairSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RepairSettings: React.FC<RepairSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -24,4 +20,4 @@ const RepairSettings: React.FC<RepairSettingsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default RepairSettings;
|
||||
export default RepairSettings;
|
||||
|
||||
@@ -1,55 +1,126 @@
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Text, Anchor } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||
import { useAllFiles } from "../../../contexts/FileContext";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: File[];
|
||||
selectedFiles?: StirlingFile[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
placeholder,
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { files: workbenchFiles } = useAllFiles();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const { files: stirlingFileStubs } = useAllFiles();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
|
||||
// Check if there are recent files
|
||||
useEffect(() => {
|
||||
const checkRecentFiles = async () => {
|
||||
try {
|
||||
const recentFiles = await loadRecentFiles();
|
||||
setHasRecentFiles(recentFiles.length > 0);
|
||||
} catch {
|
||||
setHasRecentFiles(false);
|
||||
}
|
||||
};
|
||||
checkRecentFiles();
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
// Handle native file picker
|
||||
const handleNativeUpload = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = '.pdf,application/pdf';
|
||||
input.onchange = (event) => {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
if (files.length > 0) {
|
||||
onFilesSelect(files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
// Don't render until we know if there are recent files
|
||||
if (hasRecentFiles === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there are no files in the workbench
|
||||
if (workbenchFiles.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.noFiles", "No files uploaded. ")}{" "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={openFilesModal}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '14px' }} />
|
||||
{t("files.addFiles", "Add files")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
if (stirlingFileStubs.length === 0) {
|
||||
// If no recent files, show upload button
|
||||
if (!hasRecentFiles) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={handleNativeUpload}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<UploadIcon style={{ fontSize: '0.875rem' }} />
|
||||
{t("files.upload", "Upload")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
// If there are recent files, show add files button
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||
{t("files.addFiles", "Add files")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show selection status when there are files in workbench
|
||||
if (selectedFiles.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={openFilesModal}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '14px' }} />
|
||||
{t("files.addFiles", "Add files")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
// If no recent files, show upload option
|
||||
if (!hasRecentFiles) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={handleNativeUpload}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<UploadIcon style={{ fontSize: '0.875rem' }} />
|
||||
{t("files.uploadFiles", "Upload Files")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
// If there are recent files, show add files option
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||
{t("files.addFiles", "Add files")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileStatusIndicator from './FileStatusIndicator';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesToolStepProps {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Button, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ErrorNotification from './ErrorNotification';
|
||||
import ResultsPreview from './ResultsPreview';
|
||||
import { SuggestedToolsSection } from './SuggestedToolsSection';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import ErrorNotification from "./ErrorNotification";
|
||||
import ResultsPreview from "./ResultsPreview";
|
||||
import { SuggestedToolsSection } from "./SuggestedToolsSection";
|
||||
import { ToolOperationHook } from "../../../hooks/tools/shared/useToolOperation";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
|
||||
export interface ReviewToolStepProps<TParams = unknown> {
|
||||
isVisible: boolean;
|
||||
operation: ToolOperationHook<TParams>;
|
||||
title?: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
}
|
||||
|
||||
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
|
||||
function ReviewStepContent<TParams = unknown>({
|
||||
operation,
|
||||
onFileClick,
|
||||
onUndo,
|
||||
}: {
|
||||
operation: ToolOperationHook<TParams>;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stepRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const previewFiles = operation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: operation.thumbnails[index]
|
||||
})) || [];
|
||||
const handleUndo = async () => {
|
||||
try {
|
||||
onUndo();
|
||||
} catch (error) {
|
||||
// Error is already handled by useToolOperation, just reset loading state
|
||||
console.error("Undo operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const previewFiles =
|
||||
operation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: operation.thumbnails[index],
|
||||
})) || [];
|
||||
|
||||
// Auto-scroll to bottom when content appears
|
||||
useEffect(() => {
|
||||
@@ -31,7 +52,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
||||
setTimeout(() => {
|
||||
scrollableContainer.scrollTo({
|
||||
top: scrollableContainer.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, 100); // Small delay to ensure content is rendered
|
||||
}
|
||||
@@ -40,10 +61,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
||||
|
||||
return (
|
||||
<Stack gap="sm" ref={stepRef}>
|
||||
<ErrorNotification
|
||||
error={operation.errorMessage}
|
||||
onClose={operation.clearError}
|
||||
/>
|
||||
<ErrorNotification error={operation.errorMessage} onClose={operation.clearError} />
|
||||
|
||||
{previewFiles.length > 0 && (
|
||||
<ResultsPreview
|
||||
@@ -53,7 +71,18 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation.downloadUrl && (
|
||||
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
|
||||
<Button
|
||||
leftSection={<UndoIcon />}
|
||||
variant="outline"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
onClick={handleUndo}
|
||||
fullWidth
|
||||
>
|
||||
{t("undo", "Undo")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{operation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={operation.downloadUrl}
|
||||
@@ -78,14 +107,13 @@ export function createReviewToolStep<TParams = unknown>(
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(t("review", "Review"), {
|
||||
isVisible: props.isVisible,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true
|
||||
}, (
|
||||
<ReviewStepContent
|
||||
operation={props.operation}
|
||||
onFileClick={props.onFileClick}
|
||||
/>
|
||||
));
|
||||
return createStep(
|
||||
t("review", "Review"),
|
||||
{
|
||||
isVisible: props.isVisible,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true,
|
||||
},
|
||||
<ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||
import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
|
||||
export interface SuggestedToolsSectionProps {}
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const suggestedTools = useSuggestedTools();
|
||||
@@ -21,20 +19,25 @@ export function SuggestedToolsSection(): React.ReactElement {
|
||||
{suggestedTools.map((tool) => {
|
||||
const IconComponent = tool.icon;
|
||||
return (
|
||||
<Card
|
||||
<Anchor
|
||||
key={tool.id}
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={tool.navigate}
|
||||
href={tool.href}
|
||||
onClick={tool.onClick}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
<Text size="sm" fw={500}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
<Text size="sm" fw={500}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { Text, Stack, Flex, Divider } from '@mantine/core';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { TooltipTip } from '../../../types/tips';
|
||||
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
|
||||
@@ -51,19 +50,17 @@ const renderTooltipTitle = (
|
||||
sidebarTooltip={true}
|
||||
>
|
||||
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Text fw={500} size="lg">
|
||||
<Text fw={400} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
|
||||
gpp_maybe
|
||||
</span>
|
||||
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text fw={500} size="lg">
|
||||
<Text fw={500} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
@@ -99,7 +96,7 @@ const ToolStep = ({
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
padding: '0.5rem',
|
||||
opacity: isCollapsed ? 0.8 : 1,
|
||||
color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||
transition: 'opacity 0.2s ease, color 0.2s ease'
|
||||
@@ -117,7 +114,7 @@ const ToolStep = ({
|
||||
>
|
||||
<Flex align="center" gap="sm">
|
||||
{shouldShowNumber && (
|
||||
<Text fw={500} size="lg" c="dimmed">
|
||||
<Text fw={500} size="sm" c="dimmed" mr="0.5rem">
|
||||
{stepNumber}
|
||||
</Text>
|
||||
)}
|
||||
@@ -125,14 +122,12 @@ const ToolStep = ({
|
||||
</Flex>
|
||||
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon style={{
|
||||
fontSize: '1.2rem',
|
||||
<LocalIcon icon="chevron-right-rounded" width="1.2rem" height="1.2rem" style={{
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
opacity: onCollapsedClick ? 1 : 0.5
|
||||
}} />
|
||||
) : (
|
||||
<ExpandMoreIcon style={{
|
||||
fontSize: '1.2rem',
|
||||
<LocalIcon icon="expand-more-rounded" width="1.2rem" height="1.2rem" style={{
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
opacity: onCollapsedClick ? 1 : 0.5
|
||||
}} />
|
||||
@@ -140,7 +135,7 @@ const ToolStep = ({
|
||||
</Flex>
|
||||
|
||||
{!isCollapsed && (
|
||||
<Stack gap="md" pl={_noPadding ? 0 : "md"}>
|
||||
<Stack gap="sm" pl={_noPadding ? 0 : "sm"}>
|
||||
{helpText && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{helpText}
|
||||
@@ -150,7 +145,7 @@ const ToolStep = ({
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ marginLeft: '1rem', marginRight: '-0.5rem' }} />
|
||||
<Divider style={{ color: '#E2E8F0', marginLeft: '1rem', marginRight: '-0.5rem' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Flex, Text, Divider } from '@mantine/core';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
|
||||
export interface ToolWorkflowTitleProps {
|
||||
@@ -29,9 +30,7 @@ export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
|
||||
<Text fw={500} size="xl" p="md">
|
||||
{title}
|
||||
</Text>
|
||||
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
|
||||
gpp_maybe
|
||||
</span>
|
||||
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||
import OperationButton from './OperationButton';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
@@ -43,6 +44,7 @@ export interface ReviewStepConfig {
|
||||
operation: ToolOperationHook<any>;
|
||||
title: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
@@ -65,7 +67,8 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
const steps = createToolSteps();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
|
||||
<Stack gap="sm" p="sm" >
|
||||
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
|
||||
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
||||
{config.title && <ToolWorkflowTitle {...config.title} />}
|
||||
|
||||
@@ -78,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
})}
|
||||
|
||||
{/* Middle Steps */}
|
||||
{config.steps.map((stepConfig, index) =>
|
||||
{config.steps.map((stepConfig) =>
|
||||
steps.create(stepConfig.title, {
|
||||
isVisible: stepConfig.isVisible,
|
||||
isCollapsed: stepConfig.isCollapsed,
|
||||
@@ -104,7 +107,8 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
isVisible: config.review.isVisible,
|
||||
operation: config.review.operation,
|
||||
title: config.review.title,
|
||||
onFileClick: config.review.onFileClick
|
||||
onFileClick: config.review.onFileClick,
|
||||
onUndo: config.review.onUndo
|
||||
})}
|
||||
</ToolStepProvider>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
import SubcategoryHeader from './SubcategoryHeader';
|
||||
|
||||
@@ -19,7 +18,7 @@ export const renderToolButtons = (
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
@@ -29,6 +28,6 @@ export const renderToolButtons = (
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,7 @@ interface SingleLargePageSettingsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -24,4 +20,4 @@ const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleLargePageSettings;
|
||||
export default SingleLargePageSettings;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user