Initial commit of Rotate

This commit is contained in:
James Brunton 2025-09-12 16:27:45 +01:00
parent a57373b968
commit eee9d3b022
9 changed files with 590 additions and 2 deletions

View File

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

View File

@ -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 (
<Stack gap="md">
{/* Thumbnail Preview Section */}
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("rotate.preview.title", "Rotation Preview")}
</Text>
<Center>
<Box
style={{
width: '200px',
height: '280px',
border: '2px dashed var(--mantine-color-gray-4)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-0)',
overflow: 'hidden'
}}
>
<Box
style={{
width: '100%',
height: '100%',
transform: `rotate(${currentAngle}deg)`,
transition: 'transform 0.3s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<DocumentThumbnail
file={selectedFile}
thumbnail={thumbnail}
style={{
maxWidth: '180px',
maxHeight: '260px'
}}
/>
</Box>
</Box>
</Center>
</Stack>
{/* Rotation Controls */}
<Group justify="center" gap="lg">
<ActionIcon
size="xl"
variant="outline"
onClick={parameters.rotateAnticlockwise}
disabled={disabled}
aria-label={t("rotate.rotateLeft", "Rotate Anticlockwise")}
title={t("rotate.rotateLeft", "Rotate Anticlockwise")}
>
<RotateLeftIcon style={{ fontSize: '1.5rem' }} />
</ActionIcon>
<ActionIcon
size="xl"
variant="outline"
onClick={parameters.rotateClockwise}
disabled={disabled}
aria-label={t("rotate.rotateRight", "Rotate Clockwise")}
title={t("rotate.rotateRight", "Rotate Clockwise")}
>
<RotateRightIcon style={{ fontSize: '1.5rem' }} />
</ActionIcon>
</Group>
</Stack>
);
};
export default RotateSettings;

View File

@ -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.")
}
]
};
};

View File

@ -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: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -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<RotateParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
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);
});
});

View File

@ -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<RotateParameters>({
...rotateOperationConfig,
getErrorMessage: createStandardErrorHandler(t('rotate.error.failed', 'An error occurred while rotating the PDF.'))
});
};

View File

@ -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);
});
});

View File

@ -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<RotateParameters> & {
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(<K extends keyof RotateParameters>(
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,
};
};

View File

@ -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: (
<RotateSettings
parameters={base.params}
disabled={base.endpointLoading}
/>
),
},
],
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;