From 06e52053026df5fd57d9af3bc736bc1c301adbf8 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 18 Sep 2025 11:04:12 +0100 Subject: [PATCH] V2 rotate (#4452) # Description of Changes Add Rotate tool to V2 --- .../public/locales/en-GB/translation.json | 24 ++- .../tools/rotate/RotateSettings.tsx | 104 ++++++++++++ .../src/components/tooltips/useRotateTips.ts | 21 +++ frontend/src/contexts/file/fileHooks.ts | 8 +- .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../tools/rotate/useRotateOperation.test.ts | 101 +++++++++++ .../hooks/tools/rotate/useRotateOperation.ts | 31 ++++ .../tools/rotate/useRotateParameters.test.ts | 160 ++++++++++++++++++ .../hooks/tools/rotate/useRotateParameters.ts | 67 ++++++++ .../src/hooks/tools/shared/useBaseTool.ts | 15 +- frontend/src/tools/Merge.tsx | 12 +- frontend/src/tools/Rotate.tsx | 57 +++++++ frontend/src/utils/urlMapping.ts | 1 + 13 files changed, 595 insertions(+), 15 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 24ae3e1af..017d01e06 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -797,9 +797,27 @@ "rotate": { "tags": "server side", "title": "Rotate PDF", - "header": "Rotate PDF", - "selectAngle": "Select rotation angle (in multiples of 90 degrees):", - "submit": "Rotate" + "submit": "Apply Rotation", + "error": { + "failed": "An error occurred while rotating the PDF." + }, + "preview": { + "title": "Rotation Preview" + }, + "rotateLeft": "Rotate Anticlockwise", + "rotateRight": "Rotate Clockwise", + "tooltip": { + "header": { + "title": "Rotate Settings Overview" + }, + "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..f8c1ea83d --- /dev/null +++ b/frontend/src/components/tools/rotate/RotateSettings.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState, useEffect } 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 DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail"; + +interface RotateSettingsProps { + parameters: RotateParametersHook; + disabled?: boolean; +} + +const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) => { + const { t } = useTranslation(); + const { selectedFileStubs } = useSelectedFiles(); + + // Get the first selected file for preview + const selectedStub = useMemo(() => { + return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null; + }, [selectedFileStubs]); + + // Get thumbnail for the selected file + const [thumbnail, setThumbnail] = useState(null); + + useEffect(() => { + setThumbnail(selectedStub?.thumbnailUrl || null); + }, [selectedStub]); + + // 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..b7dfd57f3 --- /dev/null +++ b/frontend/src/components/tooltips/useRotateTips.ts @@ -0,0 +1,21 @@ +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: [ + { + 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/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index 056ed2aa0..e42860562 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -123,12 +123,12 @@ export function useStirlingFileStub(fileId: FileId): { file?: File; record?: Sti /** * Hook for all files (use sparingly - causes re-renders on file list changes) */ -export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } { +export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[] } { const { state, selectors } = useFileState(); return useMemo(() => ({ files: selectors.getFiles(), - records: selectors.getStirlingFileStubs(), + fileStubs: selectors.getStirlingFileStubs(), fileIds: state.files.ids }), [state.files.ids, selectors]); } @@ -136,12 +136,12 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu /** * Hook for selected files (optimized for selection-based UI) */ -export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } { +export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[] } { const { state, selectors } = useFileState(); return useMemo(() => ({ selectedFiles: selectors.getSelectedFiles(), - selectedRecords: selectors.getSelectedStirlingFileStubs(), + selectedFileStubs: selectors.getSelectedStirlingFileStubs(), selectedFileIds: state.ui.selectedFileIds }), [state.ui.selectedFileIds, selectors]); } diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index d623eb6bf..47ef33d36 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -20,6 +20,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 ChangeMetadata from "../tools/ChangeMetadata"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; @@ -38,6 +39,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 { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; @@ -52,6 +54,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"; @@ -319,10 +322,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..53370ca5a --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts @@ -0,0 +1,101 @@ +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, expectedNormalized: 0 }, + { angle: 90, expectedNormalized: 90 }, + { angle: 180, expectedNormalized: 180 }, + { angle: 270, expectedNormalized: 270 }, + { angle: 360, expectedNormalized: 0 }, + { angle: -90, expectedNormalized: 270 }, + { angle: -180, expectedNormalized: 180 }, + { angle: -270, expectedNormalized: 90 }, + { angle: 450, expectedNormalized: 90 }, + ])('should create form data correctly with angle $angle (normalized to $expectedNormalized)', ({ angle, expectedNormalized }) => { + renderHook(() => useRotateOperation()); + + const callArgs = getToolConfig(); + + const testParameters: RotateParameters = { angle }; + const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); + const formData = callArgs.buildFormData(testParameters, testFile); + + // Verify the form data contains the file + expect(formData.get('fileInput')).toBe(testFile); + + // Verify angle parameter is normalized for backend + expect(formData.get('angle')).toBe(expectedNormalized.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: '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..3399d8b21 --- /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, normalizeAngle } 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); + // Normalize angle for backend (0, 90, 180, 270) + formData.append("angle", normalizeAngle(parameters.angle).toString()); + return formData; +}; + +// Static configuration object +export const rotateOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRotateFormData, + operationType: 'rotate', + endpoint: '/api/v1/general/rotate-pdf', + 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..6d3393fcc --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRotateParameters, defaultParameters, normalizeAngle } 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(360); + expect(normalizeAngle(result.current.parameters.angle)).toBe(0); + 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(-90); + 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(-270); + + act(() => { + result.current.rotateAnticlockwise(); + }); + expect(result.current.parameters.angle).toBe(-360); + expect(normalizeAngle(result.current.parameters.angle)).toBe(0); + 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', () => { + const { result } = renderHook(() => useRotateParameters()); + + act(() => { + result.current.updateParameter('angle', 450); + }); + expect(result.current.parameters.angle).toBe(450); + expect(normalizeAngle(result.current.parameters.angle)).toBe(90); + + act(() => { + result.current.updateParameter('angle', -90); + }); + expect(result.current.parameters.angle).toBe(-90); + expect(normalizeAngle(result.current.parameters.angle)).toBe(270); + }); + + 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); + }); +}); diff --git a/frontend/src/hooks/tools/rotate/useRotateParameters.ts b/frontend/src/hooks/tools/rotate/useRotateParameters.ts new file mode 100644 index 000000000..dff87bb8a --- /dev/null +++ b/frontend/src/hooks/tools/rotate/useRotateParameters.ts @@ -0,0 +1,67 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; +import { useMemo, useCallback } from 'react'; + +// Normalize angle to number between 0 and 359 +export const normalizeAngle = (angle: number): number => { + return ((angle % 360) + 360) % 360; +}; + +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; + }, + }); + + // Rotate clockwise by 90 degrees + const rotateClockwise = useCallback(() => { + baseHook.updateParameter('angle', baseHook.parameters.angle + 90); + }, [baseHook]); + + // Rotate anticlockwise by 90 degrees + const rotateAnticlockwise = useCallback(() => { + baseHook.updateParameter('angle', baseHook.parameters.angle - 90); + }, [baseHook]); + + // Check if rotation will actually change the document + const hasRotation = useMemo(() => { + const normalized = normalizeAngle(baseHook.parameters.angle); + return normalized !== 0; + }, [baseHook.parameters.angle, normalizeAngle]); + + // Override updateParameter - no longer normalize angles here + const updateParameter = useCallback(( + parameter: K, + value: RotateParameters[K] + ) => { + baseHook.updateParameter(parameter, value); + }, [baseHook]); + + return { + ...baseHook, + updateParameter, + rotateClockwise, + rotateAnticlockwise, + hasRotation, + normalizeAngle, + }; +}; diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts index 996fae712..56174a73a 100644 --- a/frontend/src/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/hooks/tools/shared/useBaseTool.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; import { useFileSelection } from '../../../contexts/FileContext'; import { useEndpointEnabled } from '../../useEndpointConfig'; import { BaseToolProps } from '../../../types/tool'; @@ -45,6 +45,7 @@ export function useBaseTool { + const currentFileCount = selectedFiles.length; + const prevFileCount = previousFileCount.current; + + if (prevFileCount === 0 && currentFileCount > 0) { + params.resetParameters(); + } + + previousFileCount.current = currentFileCount; + }, [selectedFiles.length]); + // Standard handlers const handleExecute = useCallback(async () => { try { diff --git a/frontend/src/tools/Merge.tsx b/frontend/src/tools/Merge.tsx index c0ed04f1b..ef9f8228a 100644 --- a/frontend/src/tools/Merge.tsx +++ b/frontend/src/tools/Merge.tsx @@ -16,7 +16,7 @@ const Merge = (props: BaseToolProps) => { // File selection hooks for custom sorting const { fileIds } = useAllFiles(); - const { selectedRecords } = useSelectedFiles(); + const { selectedFileStubs } = useSelectedFiles(); const { reorderFiles } = useFileManagement(); const base = useBaseTool( @@ -29,23 +29,23 @@ const Merge = (props: BaseToolProps) => { // Custom file sorting logic for merge tool const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => { - const sortedRecords = [...selectedRecords].sort((recordA, recordB) => { + const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => { let comparison = 0; switch (sortType) { case 'filename': - comparison = recordA.name.localeCompare(recordB.name); + comparison = stubA.name.localeCompare(stubB.name); break; case 'dateModified': - comparison = recordA.lastModified - recordB.lastModified; + comparison = stubA.lastModified - stubB.lastModified; break; } return ascending ? comparison : -comparison; }); - const selectedIds = sortedRecords.map(record => record.id); + const selectedIds = sortedStubs.map(record => record.id); const deselectedIds = fileIds.filter(id => !selectedIds.includes(id)); reorderFiles([...selectedIds, ...deselectedIds]); - }, [selectedRecords, fileIds, reorderFiles]); + }, [selectedFileStubs, fileIds, reorderFiles]); return createToolFlow({ files: { diff --git a/frontend/src/tools/Rotate.tsx b/frontend/src/tools/Rotate.tsx new file mode 100644 index 000000000..0b45d8d54 --- /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", "Apply Rotation"), + 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; diff --git a/frontend/src/utils/urlMapping.ts b/frontend/src/utils/urlMapping.ts index 909924ca6..918be3a1e 100644 --- a/frontend/src/utils/urlMapping.ts +++ b/frontend/src/utils/urlMapping.ts @@ -27,6 +27,7 @@ export const URL_TO_TOOL_MAP: Record = { '/remove-password': 'remove-password', '/single-large-page': 'single-large-page', '/repair': 'repair', + '/rotate-pdf': 'rotate', '/unlock-pdf-forms': 'unlock-pdf-forms', '/remove-certificate-sign': 'remove-certificate-sign', '/remove-cert-sign': 'remove-certificate-sign'