From eee9d3b0225f894a9ad05afb93e306869b1a7c0e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 12 Sep 2025 16:27:45 +0100 Subject: [PATCH] Initial commit of Rotate --- .../public/locales/en-GB/translation.json | 27 ++- .../tools/rotate/RotateSettings.tsx | 121 ++++++++++++++ .../src/components/tooltips/useRotateTips.ts | 22 +++ .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../tools/rotate/useRotateOperation.test.ts | 98 +++++++++++ .../hooks/tools/rotate/useRotateOperation.ts | 31 ++++ .../tools/rotate/useRotateParameters.test.ts | 156 ++++++++++++++++++ .../hooks/tools/rotate/useRotateParameters.ts | 71 ++++++++ frontend/src/tools/Rotate.tsx | 57 +++++++ 9 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/tools/rotate/RotateSettings.tsx create mode 100644 frontend/src/components/tooltips/useRotateTips.ts create mode 100644 frontend/src/hooks/tools/rotate/useRotateOperation.test.ts create mode 100644 frontend/src/hooks/tools/rotate/useRotateOperation.ts create mode 100644 frontend/src/hooks/tools/rotate/useRotateParameters.test.ts create mode 100644 frontend/src/hooks/tools/rotate/useRotateParameters.ts create mode 100644 frontend/src/tools/Rotate.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b7875c314..a5f661852 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -759,7 +759,32 @@ "title": "Rotate PDF", "header": "Rotate PDF", "selectAngle": "Select rotation angle (in multiples of 90 degrees):", - "submit": "Rotate" + "submit": "Rotate", + "error": { + "failed": "An error occurred while rotating the PDF.", + "invalidAngle": "Rotation angle must be a multiple of 90 degrees" + }, + "preview": { + "title": "Rotation Preview" + }, + "rotateLeft": "Rotate Anticlockwise", + "rotateRight": "Rotate Clockwise", + "noRotation": "No rotation", + "currentAngle": "Current rotation: {{angle}}°", + "controlsHelp": "Each click rotates by 90 degrees", + "tooltip": { + "header": { + "title": "Rotate Settings Overview" + }, + "description": { + "title": "Description", + "text": "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation." + }, + "controls": { + "title": "Controls", + "text": "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees." + } + } }, "convert": { "title": "Convert", diff --git a/frontend/src/components/tools/rotate/RotateSettings.tsx b/frontend/src/components/tools/rotate/RotateSettings.tsx new file mode 100644 index 000000000..da0dc23b6 --- /dev/null +++ b/frontend/src/components/tools/rotate/RotateSettings.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from "react"; +import { Stack, Text, Box, ActionIcon, Group, Center } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import RotateLeftIcon from "@mui/icons-material/RotateLeft"; +import RotateRightIcon from "@mui/icons-material/RotateRight"; +import { RotateParametersHook } from "../../../hooks/tools/rotate/useRotateParameters"; +import { useSelectedFiles } from "../../../contexts/file/fileHooks"; +import { useThumbnailGeneration } from "../../../hooks/useThumbnailGeneration"; +import DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail"; + +interface RotateSettingsProps { + parameters: RotateParametersHook; + disabled?: boolean; +} + +const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) => { + const { t } = useTranslation(); + const { selectedFiles } = useSelectedFiles(); + const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); + + // Get the first selected file for preview + const selectedFile = useMemo(() => { + return selectedFiles.length > 0 ? selectedFiles[0] : null; + }, [selectedFiles]); + + // Get thumbnail for the selected file + const thumbnail = useMemo(() => { + if (!selectedFile) return null; + + const pageId = `${selectedFile.fileId}-1`; + + // Try to get cached thumbnail first + const cached = getThumbnailFromCache(pageId); + if (cached) return cached; + + // Request thumbnail if not cached + requestThumbnail(pageId, selectedFile, 1).then(() => { + // Component will re-render when thumbnail is available + }); + + return null; + }, [selectedFile, getThumbnailFromCache, requestThumbnail]); + + // Calculate current angle display + const currentAngle = parameters.parameters.angle; + + return ( + + {/* Thumbnail Preview Section */} + + + {t("rotate.preview.title", "Rotation Preview")} + + +
+ + + + + +
+
+ + {/* Rotation Controls */} + + + + + + + + + +
+ ); +}; + +export default RotateSettings; diff --git a/frontend/src/components/tooltips/useRotateTips.ts b/frontend/src/components/tooltips/useRotateTips.ts new file mode 100644 index 000000000..88af92607 --- /dev/null +++ b/frontend/src/components/tooltips/useRotateTips.ts @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useRotateTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("rotate.tooltip.header.title", "Rotate Settings Overview") + }, + tips: [ + { + title: t("rotate.tooltip.description.title", "Description"), + description: t("rotate.tooltip.description.text", "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation.") + }, + { + title: t("rotate.tooltip.controls.title", "Controls"), + description: t("rotate.tooltip.controls.text", "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees.") + } + ] + }; +}; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index f3050ea01..aa0cafb09 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -18,6 +18,7 @@ import SingleLargePage from "../tools/SingleLargePage"; import UnlockPdfForms from "../tools/UnlockPdfForms"; import RemoveCertificateSign from "../tools/RemoveCertificateSign"; import Flatten from "../tools/Flatten"; +import Rotate from "../tools/Rotate"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation"; @@ -35,6 +36,7 @@ import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation'; import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation"; import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation"; import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation"; +import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; @@ -48,6 +50,7 @@ import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; import FlattenSettings from "../components/tools/flatten/FlattenSettings"; import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings"; +import RotateSettings from "../components/tools/rotate/RotateSettings"; import Redact from "../tools/Redact"; import AdjustPageScale from "../tools/AdjustPageScale"; import { ToolId } from "../types/toolId"; @@ -310,10 +313,14 @@ export function useFlatToolRegistry(): ToolRegistry { rotate: { icon: , name: t("home.rotate.title", "Rotate"), - component: null, + component: Rotate, description: t("home.rotate.desc", "Easily rotate your PDFs."), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + maxFiles: -1, + endpoints: ["rotate-pdf"], + operationConfig: rotateOperationConfig, + settingsComponent: RotateSettings, }, split: { icon: , diff --git a/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts b/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts new file mode 100644 index 000000000..beccd8c64 --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useRotateOperation } from './useRotateOperation'; +import type { RotateParameters } from './useRotateParameters'; + +// Mock the useToolOperation hook +vi.mock('../shared/useToolOperation', async () => { + const actual = await vi.importActual('../shared/useToolOperation'); + return { + ...actual, + useToolOperation: vi.fn() + }; +}); + +// Mock the translation hook +const mockT = vi.fn((key: string) => `translated-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Mock the error handler +vi.mock('../../../utils/toolErrorHandler', () => ({ + createStandardErrorHandler: vi.fn(() => 'error-handler-function') +})); + +// Import the mocked function +import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation'; + +describe('useRotateOperation', () => { + const mockUseToolOperation = vi.mocked(useToolOperation); + + const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig; + + const mockToolOperationReturn: ToolOperationHook = { + files: [], + thumbnails: [], + downloadUrl: null, + downloadFilename: '', + isLoading: false, + errorMessage: null, + status: '', + isGeneratingThumbnails: false, + progress: null, + executeOperation: vi.fn(), + resetResults: vi.fn(), + clearError: vi.fn(), + cancelOperation: vi.fn(), + undoOperation: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseToolOperation.mockReturnValue(mockToolOperationReturn); + }); + + test.each([ + { angle: 0 }, + { angle: 90 }, + { angle: 180 }, + { angle: 270 }, + ])('should create form data correctly with angle $angle', ({ angle }) => { + renderHook(() => useRotateOperation()); + + const callArgs = getToolConfig(); + const buildFormData = callArgs.buildFormData; + + const testParameters: RotateParameters = { angle }; + const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); + const formData = buildFormData(testParameters, testFile); + + // Verify the form data contains the file + expect(formData.get('fileInput')).toBe(testFile); + + // Verify angle parameter + expect(formData.get('angle')).toBe(angle.toString()); + }); + + test('should use correct translation for error messages', () => { + renderHook(() => useRotateOperation()); + + expect(mockT).toHaveBeenCalledWith( + 'rotate.error.failed', + 'An error occurred while rotating the PDF.' + ); + }); + + test.each([ + { property: 'toolType' as const, expectedValue: ToolType.singleFile }, + { property: 'endpoint' as const, expectedValue: '/api/v1/general/rotate-pdf' }, + { property: 'filePrefix' as const, expectedValue: 'rotated_' }, + { property: 'operationType' as const, expectedValue: 'rotate' } + ])('should configure $property correctly', ({ property, expectedValue }) => { + renderHook(() => useRotateOperation()); + + const callArgs = getToolConfig(); + expect(callArgs[property]).toBe(expectedValue); + }); +}); diff --git a/frontend/src/hooks/tools/rotate/useRotateOperation.ts b/frontend/src/hooks/tools/rotate/useRotateOperation.ts new file mode 100644 index 000000000..832b3a414 --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateOperation.ts @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RotateParameters, defaultParameters } from './useRotateParameters'; + +// Static configuration that can be used by both the hook and automation executor +export const buildRotateFormData = (parameters: RotateParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("angle", parameters.angle.toString()); + return formData; +}; + +// Static configuration object +export const rotateOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRotateFormData, + operationType: 'rotate', + endpoint: '/api/v1/general/rotate-pdf', + filePrefix: 'rotated_', + defaultParameters, +} as const; + +export const useRotateOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...rotateOperationConfig, + getErrorMessage: createStandardErrorHandler(t('rotate.error.failed', 'An error occurred while rotating the PDF.')) + }); +}; diff --git a/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts b/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts new file mode 100644 index 000000000..8b0169611 --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRotateParameters, defaultParameters } from './useRotateParameters'; + +describe('useRotateParameters', () => { + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useRotateParameters()); + + expect(result.current.parameters).toEqual(defaultParameters); + expect(result.current.parameters.angle).toBe(0); + expect(result.current.hasRotation).toBe(false); + }); + + test('should validate parameters correctly', () => { + const { result } = renderHook(() => useRotateParameters()); + + // Default should be valid + expect(result.current.validateParameters()).toBe(true); + + // Set invalid angle + act(() => { + result.current.updateParameter('angle', 45); + }); + expect(result.current.validateParameters()).toBe(false); + + // Set valid angle + act(() => { + result.current.updateParameter('angle', 90); + }); + expect(result.current.validateParameters()).toBe(true); + }); + + test('should rotate clockwise correctly', () => { + const { result } = renderHook(() => useRotateParameters()); + + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.parameters.angle).toBe(90); + expect(result.current.hasRotation).toBe(true); + + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.parameters.angle).toBe(180); + + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.parameters.angle).toBe(270); + + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.parameters.angle).toBe(0); // Should wrap around + expect(result.current.hasRotation).toBe(false); + }); + + test('should rotate anticlockwise correctly', () => { + const { result } = renderHook(() => useRotateParameters()); + + act(() => { + result.current.rotateAnticlockwise(); + }); + expect(result.current.parameters.angle).toBe(270); + expect(result.current.hasRotation).toBe(true); + + act(() => { + result.current.rotateAnticlockwise(); + }); + expect(result.current.parameters.angle).toBe(180); + + act(() => { + result.current.rotateAnticlockwise(); + }); + expect(result.current.parameters.angle).toBe(90); + + act(() => { + result.current.rotateAnticlockwise(); + }); + expect(result.current.parameters.angle).toBe(0); // Should wrap around + expect(result.current.hasRotation).toBe(false); + }); + + test('should normalize angles correctly', () => { + const { result } = renderHook(() => useRotateParameters()); + + expect(result.current.normalizeAngle(360)).toBe(0); + expect(result.current.normalizeAngle(450)).toBe(90); + expect(result.current.normalizeAngle(-90)).toBe(270); + expect(result.current.normalizeAngle(-180)).toBe(180); + }); + + test('should reset parameters correctly', () => { + const { result } = renderHook(() => useRotateParameters()); + + // Set some rotation + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.parameters.angle).toBe(90); + + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.parameters.angle).toBe(180); + + // Reset + act(() => { + result.current.resetParameters(); + }); + expect(result.current.parameters).toEqual(defaultParameters); + expect(result.current.hasRotation).toBe(false); + }); + + test('should update parameters and normalize angles', () => { + const { result } = renderHook(() => useRotateParameters()); + + act(() => { + result.current.updateParameter('angle', 450); + }); + expect(result.current.parameters.angle).toBe(90); // Should be normalized + + act(() => { + result.current.updateParameter('angle', -90); + }); + expect(result.current.parameters.angle).toBe(270); // Should be normalized + }); + + test('should return correct endpoint name', () => { + const { result } = renderHook(() => useRotateParameters()); + + expect(result.current.getEndpointName()).toBe('rotate-pdf'); + }); + + test('should detect rotation state correctly', () => { + const { result } = renderHook(() => useRotateParameters()); + + // Initially no rotation + expect(result.current.hasRotation).toBe(false); + + // After rotation + act(() => { + result.current.rotateClockwise(); + }); + expect(result.current.hasRotation).toBe(true); + + // After full rotation (360 degrees) - 3 more clicks to complete 360° + for (let i = 0; i < 3; i++) { + act(() => { + result.current.rotateClockwise(); + }); + } + expect(result.current.hasRotation).toBe(false); + }); +}); \ No newline at end of file diff --git a/frontend/src/hooks/tools/rotate/useRotateParameters.ts b/frontend/src/hooks/tools/rotate/useRotateParameters.ts new file mode 100644 index 000000000..2d8cb039c --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateParameters.ts @@ -0,0 +1,71 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; +import { useMemo, useCallback } from 'react'; + +export interface RotateParameters extends BaseParameters { + angle: number; // Current rotation angle (0, 90, 180, 270) +} + +export const defaultParameters: RotateParameters = { + angle: 0, +}; + +export type RotateParametersHook = BaseParametersHook & { + rotateClockwise: () => void; + rotateAnticlockwise: () => void; + hasRotation: boolean; + normalizeAngle: (angle: number) => number; +}; + +export const useRotateParameters = (): RotateParametersHook => { + const baseHook = useBaseParameters({ + defaultParameters, + endpointName: 'rotate-pdf', + validateFn: (params) => { + // Angle must be a multiple of 90 + return params.angle % 90 === 0; + }, + }); + + // Normalize angle to valid backend values (0, 90, 180, 270) + const normalizeAngle = useCallback((angle: number): number => { + const normalized = angle % 360; + return normalized < 0 ? normalized + 360 : normalized; + }, []); + + // Rotate clockwise by 90 degrees + const rotateClockwise = useCallback(() => { + baseHook.updateParameter('angle', normalizeAngle(baseHook.parameters.angle + 90)); + }, [baseHook, normalizeAngle]); + + // Rotate anticlockwise by 90 degrees + const rotateAnticlockwise = useCallback(() => { + baseHook.updateParameter('angle', normalizeAngle(baseHook.parameters.angle - 90)); + }, [baseHook, normalizeAngle]); + + // Check if rotation will actually change the document + const hasRotation = useMemo(() => { + return baseHook.parameters.angle !== 0; + }, [baseHook.parameters.angle]); + + // Override updateParameter to normalize angles + const updateParameter = useCallback(( + parameter: K, + value: RotateParameters[K] + ) => { + if (parameter === 'angle') { + baseHook.updateParameter(parameter, normalizeAngle(value as number) as RotateParameters[K]); + } else { + baseHook.updateParameter(parameter, value); + } + }, [baseHook, normalizeAngle]); + + return { + ...baseHook, + updateParameter, + rotateClockwise, + rotateAnticlockwise, + hasRotation, + normalizeAngle, + }; +}; diff --git a/frontend/src/tools/Rotate.tsx b/frontend/src/tools/Rotate.tsx new file mode 100644 index 000000000..e68ec0ba7 --- /dev/null +++ b/frontend/src/tools/Rotate.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import RotateSettings from "../components/tools/rotate/RotateSettings"; +import { useRotateParameters } from "../hooks/tools/rotate/useRotateParameters"; +import { useRotateOperation } from "../hooks/tools/rotate/useRotateOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useRotateTips } from "../components/tooltips/useRotateTips"; + +const Rotate = (props: BaseToolProps) => { + const { t } = useTranslation(); + const rotateTips = useRotateTips(); + + const base = useBaseTool( + 'rotate', + useRotateParameters, + useRotateOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: "Settings", + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: rotateTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("rotate.submit", "Rotate"), + 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("rotate.title", "Rotation Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default Rotate as ToolComponent;