Merge branch 'V2' into update-frontend-3rd-party-licenses

This commit is contained in:
ConnorYoh 2025-09-05 15:46:22 +01:00 committed by GitHub
commit 0c7a5935ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 861 additions and 73 deletions

300
ADDING_TOOLS.md Normal file
View File

@ -0,0 +1,300 @@
# Adding New React Tools to Stirling PDF
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
## Overview
When adding tools, follow this systematic approach using the established patterns and architecture.
## 1. Create Tool Structure
Create these files in the correct directories:
```
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
## 2. Implementation Pattern
Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools:
**Parameters Hook** (`use[ToolName]Parameters.ts`):
```typescript
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface [ToolName]Parameters extends BaseParameters {
// Define your tool-specific parameters here
someOption: boolean;
}
export const defaultParameters: [ToolName]Parameters = {
someOption: false,
};
export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => {
return useBaseParameters({
defaultParameters,
endpointName: 'your-endpoint-name',
validateFn: (params) => true, // Add validation logic
});
};
```
**Operation Hook** (`use[ToolName]Operation.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters to formData
return formData;
};
export const [toolName]OperationConfig = {
toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated)
buildFormData: build[ToolName]FormData,
operationType: '[toolName]',
endpoint: '/api/v1/category/endpoint-name',
filePrefix: 'processed_', // Will be overridden with translation
defaultParameters,
} as const;
export const use[ToolName]Operation = () => {
const { t } = useTranslation();
return useToolOperation({
...[toolName]OperationConfig,
filePrefix: t('[toolName].filenamePrefix', 'processed') + '_',
getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed'))
});
};
```
**Main Component** (`[ToolName].tsx`):
```typescript
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters";
import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const [ToolName] = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("[toolName].files.placeholder", "Select files to get started"),
},
steps: [
// Add settings steps if needed
],
executeButton: {
text: t("[toolName].submit", "Process"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("[toolName].results.title", "Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
[ToolName].tool = () => use[ToolName]Operation;
export default [ToolName] as ToolComponent;
```
**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support.
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings";
```
2. Add tool entry in the `allTools` object:
```typescript
[toolName]: {
icon: <LocalIcon icon="your-icon-name" width="1.5rem" height="1.5rem" />,
name: t("home.[toolName].title", "Tool Name"),
component: [ToolName],
description: t("home.[toolName].desc", "Tool description"),
categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
maxFiles: -1, // or specific number
endpoints: ["endpoint-name"],
operationConfig: [toolName]OperationConfig,
settingsComponent: [ToolName]Settings, // if settings exist
},
```
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const use[ToolName]Tips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("[toolName].tooltip.header.title", "Tool Overview")
},
tips: [
{
title: t("[toolName].tooltip.description.title", "What does this tool do?"),
description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."),
bullets: [
t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"),
t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2")
]
}
// Add more tip sections as needed
]
};
};
```
**Add tooltip to your main component:**
```typescript
import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
const [ToolName] = (props: BaseToolProps) => {
const tips = use[ToolName]Tips();
// In your steps array:
steps: [
{
title: t("[toolName].steps.settings", "Settings"),
tooltip: tips, // Add this line
content: <[ToolName]Settings ... />
}
]
```
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**:
```json
{
"home": {
"[toolName]": {
"title": "Tool Name",
"desc": "Tool description"
}
},
"[toolName]": {
"title": "Tool Name",
"submit": "Process",
"filenamePrefix": "processed",
"files": {
"placeholder": "Select files to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"title": "Tool Options",
"someOption": "Option Label",
"someOption.desc": "Option description",
"note": "General information about the tool."
},
"results": {
"title": "Results"
},
"error": {
"failed": "Operation failed"
},
"tooltip": {
"header": {
"title": "Tool Overview"
},
"description": {
"title": "What does this tool do?",
"text": "Simple explanation in everyday language",
"bullet1": "Easy-to-understand benefit 1",
"bullet2": "Easy-to-understand benefit 2"
}
}
}
}
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
**Tooltip Writing Guidelines:**
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
- **Focus on benefits** - explain what the user gains, not how it works internally
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
- **Keep descriptions concise** - 1-2 sentences maximum per section
- **Use bullet points** for multiple benefits or features
## 6. Migration from Thymeleaf
When migrating existing Thymeleaf templates:
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
## 7. Testing Your Tool
- Verify tool appears in UI with correct icon and description
- Test with various file sizes and types
- Confirm translations work
- Check error handling
- Test undo functionality
- Verify results display correctly
## Tool Development Patterns
### Three Tool Patterns:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR

View File

@ -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

View File

@ -1305,7 +1305,48 @@
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
"submit": "Flatten"
"submit": "Flatten",
"filenamePrefix": "flattened",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"stepTitle": "Flatten Options",
"title": "Flatten Options",
"flattenOnlyForms": "Flatten only forms",
"flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact",
"note": "Flattening removes interactive elements from the PDF, making them non-editable."
},
"results": {
"title": "Flatten Results"
},
"error": {
"failed": "An error occurred while flattening the PDF."
},
"tooltip": {
"header": {
"title": "About Flattening PDFs"
},
"description": {
"title": "What does flattening do?",
"text": "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere.",
"bullet1": "Text boxes become regular text (can't be edited)",
"bullet2": "Checkboxes and buttons become pictures",
"bullet3": "Great for final versions you don't want changed",
"bullet4": "Ensures consistent appearance across all devices"
},
"formsOnly": {
"title": "What does 'Flatten only forms' mean?",
"text": "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments.",
"bullet1": "Forms become non-editable",
"bullet2": "Links still work when clicked",
"bullet3": "Comments and notes remain visible",
"bullet4": "Bookmarks still help you navigate"
}
}
},
"repair": {
"tags": "fix,restore,correction,recover",

View File

@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from './Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
interface AllToolsNavButtonProps {
activeButton: string;
@ -13,6 +15,7 @@ interface AllToolsNavButtonProps {
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => {
setActiveButton('tools');
@ -24,6 +27,12 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, handleClick);
};
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} />
@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
);
return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={'lg'}
variant="subtle"
onClick={handleClick}
aria-label={t("quickAccess.allTools", "All Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
border: 'none',
borderRadius: '8px',
textDecoration: 'none'
}}
className={isActive ? 'activeIconScale' : ''}
>

View File

@ -6,6 +6,8 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider";
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';
@ -21,6 +23,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@ -35,6 +38,52 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
openFilesModal();
};
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
config.onClick();
}
};
// Render navigation button with conditional URL support
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick()
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
);
};
const buttonConfigs: ButtonConfig[] = [
{
@ -151,27 +200,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Stack gap="lg" align="center">
{buttonConfigs.slice(0, -1).map((config, index) => (
<React.Fragment key={config.id}>
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'lg') : 'lg'}
variant="subtle"
onClick={() => {
config.onClick();
}}
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
{renderNavButton(config, index)}
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
{index === 1 && (

View File

@ -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';
@ -28,6 +30,7 @@ const NAV_IDS = ['read', 'sign', 'automate'];
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(
@ -135,21 +138,26 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
<div className="flex flex-col items-center gap-1">
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
<ActionIcon
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
setActiveButton('tools');
handleBackToTools();
});
}}
size={'xl'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
onClick={() => {
setActiveButton('tools');
handleBackToTools();
}}
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
style={{
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
border: 'none',
borderRadius: '8px',
cursor: 'pointer'
cursor: 'pointer',
textDecoration: 'none'
}}
>
<span className="iconContainer">

View File

@ -0,0 +1,35 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { FlattenParameters } from "../../../hooks/tools/flatten/useFlattenParameters";
interface FlattenSettingsProps {
parameters: FlattenParameters;
onParameterChange: <K extends keyof FlattenParameters>(key: K, value: FlattenParameters[K]) => void;
disabled?: boolean;
}
const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Stack gap="sm">
<Checkbox
checked={parameters.flattenOnlyForms}
onChange={(event) => onParameterChange('flattenOnlyForms', event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{t('flatten.options.flattenOnlyForms', 'Flatten only forms')}</Text>
<Text size="xs" c="dimmed">
{t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')}
</Text>
</div>
}
/>
</Stack>
</Stack>
);
};
export default FlattenSettings;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
@ -19,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>

View File

@ -2,6 +2,8 @@ import React from "react";
import { Button } from "@mantine/core";
import { Tooltip } from "../../shared/Tooltip";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { useToolNavigation } from "../../../hooks/useToolNavigation";
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
interface ToolButtonProps {
@ -14,6 +16,8 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const isUnavailable = !tool.component && !tool.link;
const { getToolNavigation } = useToolNavigation();
const handleClick = (id: string) => {
if (isUnavailable) return;
if (tool.link) {
@ -25,32 +29,84 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
onSelect(id);
};
// Get navigation props for URL support
const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null;
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
: tool.description;
const buttonContent = (
<>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
</>
);
const handleExternalClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => handleClick(id));
};
const buttonElement = navProps ? (
// For internal tools with URLs, render Button as an anchor for proper link behavior
<Button
component="a"
href={navProps.href}
onClick={navProps.onClick}
variant={isSelected ? "filled" : "subtle"}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{buttonContent}
</Button>
) : tool.link && !isUnavailable ? (
// For external links, render Button as an anchor with proper href
<Button
component="a"
href={tool.link}
target="_blank"
rel="noopener noreferrer"
onClick={handleExternalClick}
variant={isSelected ? "filled" : "subtle"}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{buttonContent}
</Button>
) : (
// For unavailable tools, use regular button
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={() => handleClick(id)}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
{buttonContent}
</Button>
);
return (
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={()=> handleClick(id)}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
</Button>
{buttonElement}
</Tooltip>
);
};

View File

@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useFlattenTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("flatten.tooltip.header.title", "About Flattening PDFs")
},
tips: [
{
title: t("flatten.tooltip.description.title", "What does flattening do?"),
description: t("flatten.tooltip.description.text", "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere."),
bullets: [
t("flatten.tooltip.description.bullet1", "Text boxes become regular text (can't be edited)"),
t("flatten.tooltip.description.bullet2", "Checkboxes and buttons become pictures"),
t("flatten.tooltip.description.bullet3", "Great for final versions you don't want changed"),
t("flatten.tooltip.description.bullet4", "Ensures consistent appearance across all devices")
]
},
{
title: t("flatten.tooltip.formsOnly.title", "What does 'Flatten only forms' mean?"),
description: t("flatten.tooltip.formsOnly.text", "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments."),
bullets: [
t("flatten.tooltip.formsOnly.bullet1", "Forms become non-editable"),
t("flatten.tooltip.formsOnly.bullet2", "Links still work when clicked"),
t("flatten.tooltip.formsOnly.bullet3", "Comments and notes remain visible"),
t("flatten.tooltip.formsOnly.bullet4", "Bookmarks still help you navigate")
]
}
]
};
};

View File

@ -15,6 +15,7 @@ import Repair from "../tools/Repair";
import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import Flatten from "../tools/Flatten";
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
@ -28,6 +29,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@ -39,6 +41,7 @@ import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/Add
import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import { ToolId } from "../types/toolId";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -198,10 +201,14 @@ export function useFlatToolRegistry(): ToolRegistry {
flatten: {
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.flatten.title", "Flatten"),
component: null,
component: Flatten,
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["flatten"],
operationConfig: flattenOperationConfig,
settingsComponent: FlattenSettings,
},
"unlock-pdf-forms": {
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
@ -355,6 +362,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
urlPath: '/pdf-to-single-page',
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig,
},
@ -681,6 +689,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
urlPath: '/ocr-pdf',
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings,
},

View File

@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { FlattenParameters, defaultParameters } from './useFlattenParameters';
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (parameters: FlattenParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('flattenOnlyForms', parameters.flattenOnlyForms.toString());
return formData;
};
// Static configuration object
export const flattenOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFlattenFormData,
operationType: 'flatten',
endpoint: '/api/v1/misc/flatten',
filePrefix: 'flattened_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useFlattenOperation = () => {
const { t } = useTranslation();
return useToolOperation<FlattenParameters>({
...flattenOperationConfig,
filePrefix: t('flatten.filenamePrefix', 'flattened') + '_',
getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.'))
});
};

View File

@ -0,0 +1,19 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface FlattenParameters extends BaseParameters {
flattenOnlyForms: boolean;
}
export const defaultParameters: FlattenParameters = {
flattenOnlyForms: false,
};
export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
export const useFlattenParameters = (): FlattenParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'flatten',
});
};

View File

@ -0,0 +1,51 @@
import { useCallback } from 'react';
import { useToolNavigation } from './useToolNavigation';
import { useToolManagement } from './useToolManagement';
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
export interface SidebarNavigationProps {
/** Full URL for the navigation (for href attribute) */
href: string;
/** Click handler that maintains SPA behavior */
onClick: (e: React.MouseEvent) => void;
}
/**
* Hook that provides URL and navigation handlers for sidebar navigation buttons
* Supports special routes like home ('/') and specific tool routes
*/
export function useSidebarNavigation(): {
getHomeNavigation: () => SidebarNavigationProps;
getToolNavigation: (toolId: string) => SidebarNavigationProps | null;
} {
const { getToolNavigation: getToolNavProps } = useToolNavigation();
const { getSelectedTool } = useToolManagement();
const defaultNavClick = useCallback((e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
// SPA navigation will be handled by the calling component
});
}, []);
const getHomeNavigation = useCallback((): SidebarNavigationProps => {
const href = '/'; // SSR-safe relative path
return { href, onClick: defaultNavClick };
}, [defaultNavClick]);
const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => {
// Handle special nav sections that aren't tools
if (toolId === 'read') return { href: '/read', onClick: defaultNavClick };
if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick };
const tool = getSelectedTool(toolId);
if (!tool) return null;
// Delegate to useToolNavigation for true tools
return getToolNavProps(toolId, tool);
}, [getToolNavProps, getSelectedTool, defaultNavClick]);
return {
getHomeNavigation,
getToolNavigation
};
}

View File

@ -1,5 +1,7 @@
import { useMemo } from 'react';
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
import { useNavigationState } from '../contexts/NavigationContext';
import { useToolNavigation } from './useToolNavigation';
import { useToolManagement } from './useToolManagement';
import { ToolId } from '../types/toolId';
// Material UI Icons
@ -13,10 +15,11 @@ export interface SuggestedTool {
id: ToolId;
title: string;
icon: React.ComponentType<any>;
navigate: () => void;
href: string;
onClick: (e: React.MouseEvent) => void;
}
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'href' | 'onClick'>[] = [
{
id: 'compress',
title: 'Compress',
@ -45,17 +48,31 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
];
export function useSuggestedTools(): SuggestedTool[] {
const { actions } = useNavigationActions();
const { selectedTool } = useNavigationState();
const { getToolNavigation } = useToolNavigation();
const { getSelectedTool } = useToolManagement();
return useMemo(() => {
// Filter out the current tool
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedTool);
// Add navigation function to each tool
return filteredTools.map(tool => ({
...tool,
navigate: () => actions.setSelectedTool(tool.id)
}));
}, [selectedTool, actions]);
// Add navigation props to each tool
return filteredTools.map(tool => {
const toolRegistryEntry = getSelectedTool(tool.id);
if (!toolRegistryEntry) {
// Fallback for tools not in registry
return {
...tool,
href: `/${tool.id}`,
onClick: (e: React.MouseEvent) => { e.preventDefault(); }
};
}
const navProps = getToolNavigation(tool.id, toolRegistryEntry);
return {
...tool,
...navProps
};
});
}, [selectedTool, getToolNavigation, getSelectedTool]);
}

View File

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
export interface ToolNavigationProps {
/** Full URL for the tool (for href attribute) */
href: string;
/** Click handler that maintains SPA behavior */
onClick: (e: React.MouseEvent) => void;
}
/**
* Hook that provides URL and navigation handlers for tools
* Enables right-click "Open in New Tab" while maintaining SPA behavior for regular clicks
*/
export function useToolNavigation(): {
getToolNavigation: (toolId: string, tool: ToolRegistryEntry) => ToolNavigationProps;
} {
const { handleToolSelect } = useToolWorkflow();
const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => {
// Generate SSR-safe relative path
const path = getToolUrlPath(toolId, tool);
const href = path; // Relative path, no window.location needed
// Click handler that maintains SPA behavior
const onClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
// Handle external links normally
if (tool.link) {
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
}
// Use SPA navigation for internal tools
handleToolSelect(toolId);
});
};
return { href, onClick };
}, [handleToolSelect]);
return { getToolNavigation };
}

View File

@ -0,0 +1,62 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import { useFlattenParameters } from "../hooks/tools/flatten/useFlattenParameters";
import { useFlattenOperation } from "../hooks/tools/flatten/useFlattenOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { useFlattenTips } from "../components/tooltips/useFlattenTips";
import { BaseToolProps, ToolComponent } from "../types/tool";
const Flatten = (props: BaseToolProps) => {
const { t } = useTranslation();
const flattenTips = useFlattenTips();
const base = useBaseTool(
'flatten',
useFlattenParameters,
useFlattenOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{
title: t("flatten.options.stepTitle", "Flatten Options"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: flattenTips,
content: (
<FlattenSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("flatten.submit", "Flatten PDF"),
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("flatten.results.title", "Flatten Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
// Static method to get the operation hook for automation
Flatten.tool = () => useFlattenOperation;
export default Flatten as ToolComponent;

View File

@ -0,0 +1,31 @@
/**
* Utility functions for handling click events in navigation components
*/
/**
* Determines if a click event is a "special" click that should use browser's default navigation
* instead of SPA navigation. Special clicks include:
* - Ctrl+click (or Cmd+click on Mac)
* - Shift+click
* - Middle mouse button click
*/
export function isSpecialClick(e: React.MouseEvent): boolean {
return e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1;
}
/**
* Handles a click event for SPA navigation, but allows special clicks to use browser defaults
*
* @param e - The click event
* @param handleClick - Function to execute for regular clicks (SPA navigation)
* @returns true if the event was handled as a special click, false if it was handled as regular click
*/
export function handleUnlessSpecialClick(e: React.MouseEvent, handleClick: () => void): boolean {
if (isSpecialClick(e)) {
return true; // Let browser handle via href
}
e.preventDefault();
handleClick();
return false;
}