diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md new file mode 100644 index 000000000..ef1501bfc --- /dev/null +++ b/ADDING_TOOLS.md @@ -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: , + 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 `
` 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 diff --git a/CLAUDE.md b/CLAUDE.md index be4e92201..a806d0098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 637ab59e1..5f61f7544 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx index c1b5774d2..62fad704e 100644 --- a/frontend/src/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -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 = ({ 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 = ({ 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 = ( @@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC = ({ activeButton, set ); return ( -
diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 142920942..2ebe45002 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -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((_, 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('tools'); const scrollableRef = useRef(null); @@ -35,6 +38,52 @@ const QuickAccessBar = forwardRef((_, 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 ( +
+ 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`} + > + + {config.icon} + + + + {config.name} + +
+ ); + }; + const buttonConfigs: ButtonConfig[] = [ { @@ -151,27 +200,7 @@ const QuickAccessBar = forwardRef((_, ref) => { {buttonConfigs.slice(0, -1).map((config, index) => ( - -
- { - config.onClick(); - }} - style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} - className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''} - data-testid={`${config.id}-button`} - > - - {config.icon} - - - - {config.name} - -
- + {renderNavButton(config, index)} {/* Add divider after Automate button (index 1) and Files button (index 2) */} {index === 1 && ( diff --git a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx index 3a0d5fe15..0146d86e0 100644 --- a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -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 = ({ 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 = ({ setActiveButton })
{ + 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' }} > diff --git a/frontend/src/components/tools/flatten/FlattenSettings.tsx b/frontend/src/components/tools/flatten/FlattenSettings.tsx new file mode 100644 index 000000000..8386ad493 --- /dev/null +++ b/frontend/src/components/tools/flatten/FlattenSettings.tsx @@ -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: (key: K, value: FlattenParameters[K]) => void; + disabled?: boolean; +} + +const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('flattenOnlyForms', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('flatten.options.flattenOnlyForms', 'Flatten only forms')} + + {t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')} + +
+ } + /> +
+
+ ); +}; + +export default FlattenSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx index c1299a749..18cf8a0d5 100644 --- a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx +++ b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx @@ -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 ( - - - - - {tool.title} - - - + + + + + {tool.title} + + + + ); })} diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index bd54f7e13..ee9c6062c 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -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 = ({ 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 = ({ id, tool, isSelected, onSelect onSelect(id); }; + // Get navigation props for URL support + const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null; + const tooltipContent = isUnavailable ? (Coming soon: {tool.description}) : tool.description; + const buttonContent = ( + <> +
{tool.icon}
+ + + ); + + 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 + + ) : tool.link && !isUnavailable ? ( + // For external links, render Button as an anchor with proper href + + ) : ( + // For unavailable tools, use regular button + + ); + return ( - + {buttonElement} ); }; diff --git a/frontend/src/components/tooltips/useFlattenTips.ts b/frontend/src/components/tooltips/useFlattenTips.ts new file mode 100644 index 000000000..a60e5a4bc --- /dev/null +++ b/frontend/src/components/tooltips/useFlattenTips.ts @@ -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") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 68883fe92..282653f8f 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -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: , 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: , @@ -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, }, diff --git a/frontend/src/hooks/tools/flatten/useFlattenOperation.ts b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts new file mode 100644 index 000000000..82eaba258 --- /dev/null +++ b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts @@ -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({ + ...flattenOperationConfig, + filePrefix: t('flatten.filenamePrefix', 'flattened') + '_', + getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/flatten/useFlattenParameters.ts b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts new file mode 100644 index 000000000..98c5b9655 --- /dev/null +++ b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts @@ -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; + +export const useFlattenParameters = (): FlattenParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'flatten', + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useSidebarNavigation.ts b/frontend/src/hooks/useSidebarNavigation.ts new file mode 100644 index 000000000..fb60e2502 --- /dev/null +++ b/frontend/src/hooks/useSidebarNavigation.ts @@ -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 + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSuggestedTools.ts b/frontend/src/hooks/useSuggestedTools.ts index 8478bbc6b..377cf1245 100644 --- a/frontend/src/hooks/useSuggestedTools.ts +++ b/frontend/src/hooks/useSuggestedTools.ts @@ -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; - navigate: () => void; + href: string; + onClick: (e: React.MouseEvent) => void; } -const ALL_SUGGESTED_TOOLS: Omit[] = [ +const ALL_SUGGESTED_TOOLS: Omit[] = [ { id: 'compress', title: 'Compress', @@ -45,17 +48,31 @@ const ALL_SUGGESTED_TOOLS: Omit[] = [ ]; 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]); } diff --git a/frontend/src/hooks/useToolNavigation.ts b/frontend/src/hooks/useToolNavigation.ts new file mode 100644 index 000000000..704fd5026 --- /dev/null +++ b/frontend/src/hooks/useToolNavigation.ts @@ -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 }; +} \ No newline at end of file diff --git a/frontend/src/tools/Flatten.tsx b/frontend/src/tools/Flatten.tsx new file mode 100644 index 000000000..691a733f9 --- /dev/null +++ b/frontend/src/tools/Flatten.tsx @@ -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: ( + + ), + }, + ], + 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; \ No newline at end of file diff --git a/frontend/src/utils/clickHandlers.ts b/frontend/src/utils/clickHandlers.ts new file mode 100644 index 000000000..0c70cfca8 --- /dev/null +++ b/frontend/src/utils/clickHandlers.ts @@ -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; +} \ No newline at end of file