mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
Initial commit of Rotate
This commit is contained in:
parent
a57373b968
commit
eee9d3b022
@ -759,7 +759,32 @@
|
|||||||
"title": "Rotate PDF",
|
"title": "Rotate PDF",
|
||||||
"header": "Rotate PDF",
|
"header": "Rotate PDF",
|
||||||
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
|
"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": {
|
"convert": {
|
||||||
"title": "Convert",
|
"title": "Convert",
|
||||||
|
121
frontend/src/components/tools/rotate/RotateSettings.tsx
Normal file
121
frontend/src/components/tools/rotate/RotateSettings.tsx
Normal 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;
|
22
frontend/src/components/tooltips/useRotateTips.ts
Normal file
22
frontend/src/components/tooltips/useRotateTips.ts
Normal 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.")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
@ -18,6 +18,7 @@ import SingleLargePage from "../tools/SingleLargePage";
|
|||||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||||
import Flatten from "../tools/Flatten";
|
import Flatten from "../tools/Flatten";
|
||||||
|
import Rotate from "../tools/Rotate";
|
||||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
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 { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||||
|
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
||||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
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 ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||||
|
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||||
import Redact from "../tools/Redact";
|
import Redact from "../tools/Redact";
|
||||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||||
import { ToolId } from "../types/toolId";
|
import { ToolId } from "../types/toolId";
|
||||||
@ -310,10 +313,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
rotate: {
|
rotate: {
|
||||||
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.rotate.title", "Rotate"),
|
name: t("home.rotate.title", "Rotate"),
|
||||||
component: null,
|
component: Rotate,
|
||||||
description: t("home.rotate.desc", "Easily rotate your PDFs."),
|
description: t("home.rotate.desc", "Easily rotate your PDFs."),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["rotate-pdf"],
|
||||||
|
operationConfig: rotateOperationConfig,
|
||||||
|
settingsComponent: RotateSettings,
|
||||||
},
|
},
|
||||||
split: {
|
split: {
|
||||||
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
98
frontend/src/hooks/tools/rotate/useRotateOperation.test.ts
Normal file
98
frontend/src/hooks/tools/rotate/useRotateOperation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
31
frontend/src/hooks/tools/rotate/useRotateOperation.ts
Normal file
31
frontend/src/hooks/tools/rotate/useRotateOperation.ts
Normal 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.'))
|
||||||
|
});
|
||||||
|
};
|
156
frontend/src/hooks/tools/rotate/useRotateParameters.test.ts
Normal file
156
frontend/src/hooks/tools/rotate/useRotateParameters.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
71
frontend/src/hooks/tools/rotate/useRotateParameters.ts
Normal file
71
frontend/src/hooks/tools/rotate/useRotateParameters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
57
frontend/src/tools/Rotate.tsx
Normal file
57
frontend/src/tools/Rotate.tsx
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user