mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-19 01:49:24 +00:00
Merge branch 'V2' into feature/V2/BulkSelectionPanel
This commit is contained in:
commit
5f911d600c
@ -797,9 +797,27 @@
|
|||||||
"rotate": {
|
"rotate": {
|
||||||
"tags": "server side",
|
"tags": "server side",
|
||||||
"title": "Rotate PDF",
|
"title": "Rotate PDF",
|
||||||
"header": "Rotate PDF",
|
"submit": "Apply Rotation",
|
||||||
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
|
"error": {
|
||||||
"submit": "Rotate"
|
"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": {
|
"convert": {
|
||||||
"title": "Convert",
|
"title": "Convert",
|
||||||
|
104
frontend/src/components/tools/rotate/RotateSettings.tsx
Normal file
104
frontend/src/components/tools/rotate/RotateSettings.tsx
Normal file
@ -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<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThumbnail(selectedStub?.thumbnailUrl || null);
|
||||||
|
}, [selectedStub]);
|
||||||
|
|
||||||
|
// 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: '280px',
|
||||||
|
height: '280px',
|
||||||
|
border: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
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={selectedStub}
|
||||||
|
thumbnail={thumbnail}
|
||||||
|
/>
|
||||||
|
</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;
|
21
frontend/src/components/tooltips/useRotateTips.ts
Normal file
21
frontend/src/components/tooltips/useRotateTips.ts
Normal file
@ -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."),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
@ -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)
|
* 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();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getFiles(),
|
files: selectors.getFiles(),
|
||||||
records: selectors.getStirlingFileStubs(),
|
fileStubs: selectors.getStirlingFileStubs(),
|
||||||
fileIds: state.files.ids
|
fileIds: state.files.ids
|
||||||
}), [state.files.ids, selectors]);
|
}), [state.files.ids, selectors]);
|
||||||
}
|
}
|
||||||
@ -136,12 +136,12 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
|
|||||||
/**
|
/**
|
||||||
* Hook for selected files (optimized for selection-based UI)
|
* 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();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
selectedFiles: selectors.getSelectedFiles(),
|
selectedFiles: selectors.getSelectedFiles(),
|
||||||
selectedRecords: selectors.getSelectedStirlingFileStubs(),
|
selectedFileStubs: selectors.getSelectedStirlingFileStubs(),
|
||||||
selectedFileIds: state.ui.selectedFileIds
|
selectedFileIds: state.ui.selectedFileIds
|
||||||
}), [state.ui.selectedFileIds, selectors]);
|
}), [state.ui.selectedFileIds, selectors]);
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,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 ChangeMetadata from "../tools/ChangeMetadata";
|
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||||
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";
|
||||||
@ -38,6 +39,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 { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||||
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";
|
||||||
@ -52,6 +54,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";
|
||||||
@ -319,10 +322,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" />,
|
||||||
|
101
frontend/src/hooks/tools/rotate/useRotateOperation.test.ts
Normal file
101
frontend/src/hooks/tools/rotate/useRotateOperation.test.ts
Normal file
@ -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<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, 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);
|
||||||
|
});
|
||||||
|
});
|
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, 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<RotateParameters>({
|
||||||
|
...rotateOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(t('rotate.error.failed', 'An error occurred while rotating the PDF.'))
|
||||||
|
});
|
||||||
|
};
|
160
frontend/src/hooks/tools/rotate/useRotateParameters.test.ts
Normal file
160
frontend/src/hooks/tools/rotate/useRotateParameters.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
67
frontend/src/hooks/tools/rotate/useRotateParameters.ts
Normal file
67
frontend/src/hooks/tools/rotate/useRotateParameters.ts
Normal file
@ -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<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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(<K extends keyof RotateParameters>(
|
||||||
|
parameter: K,
|
||||||
|
value: RotateParameters[K]
|
||||||
|
) => {
|
||||||
|
baseHook.updateParameter(parameter, value);
|
||||||
|
}, [baseHook]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseHook,
|
||||||
|
updateParameter,
|
||||||
|
rotateClockwise,
|
||||||
|
rotateAnticlockwise,
|
||||||
|
hasRotation,
|
||||||
|
normalizeAngle,
|
||||||
|
};
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { useFileSelection } from '../../../contexts/FileContext';
|
import { useFileSelection } from '../../../contexts/FileContext';
|
||||||
import { useEndpointEnabled } from '../../useEndpointConfig';
|
import { useEndpointEnabled } from '../../useEndpointConfig';
|
||||||
import { BaseToolProps } from '../../../types/tool';
|
import { BaseToolProps } from '../../../types/tool';
|
||||||
@ -45,6 +45,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
|
|
||||||
// File selection
|
// File selection
|
||||||
const { selectedFiles } = useFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
const previousFileCount = useRef(selectedFiles.length);
|
||||||
|
|
||||||
// Tool-specific hooks
|
// Tool-specific hooks
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -67,6 +68,18 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
}
|
}
|
||||||
}, [selectedFiles.length]);
|
}, [selectedFiles.length]);
|
||||||
|
|
||||||
|
// Reset parameters when transitioning from 0 files to at least 1 file
|
||||||
|
useEffect(() => {
|
||||||
|
const currentFileCount = selectedFiles.length;
|
||||||
|
const prevFileCount = previousFileCount.current;
|
||||||
|
|
||||||
|
if (prevFileCount === 0 && currentFileCount > 0) {
|
||||||
|
params.resetParameters();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousFileCount.current = currentFileCount;
|
||||||
|
}, [selectedFiles.length]);
|
||||||
|
|
||||||
// Standard handlers
|
// Standard handlers
|
||||||
const handleExecute = useCallback(async () => {
|
const handleExecute = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -16,7 +16,7 @@ const Merge = (props: BaseToolProps) => {
|
|||||||
|
|
||||||
// File selection hooks for custom sorting
|
// File selection hooks for custom sorting
|
||||||
const { fileIds } = useAllFiles();
|
const { fileIds } = useAllFiles();
|
||||||
const { selectedRecords } = useSelectedFiles();
|
const { selectedFileStubs } = useSelectedFiles();
|
||||||
const { reorderFiles } = useFileManagement();
|
const { reorderFiles } = useFileManagement();
|
||||||
|
|
||||||
const base = useBaseTool(
|
const base = useBaseTool(
|
||||||
@ -29,23 +29,23 @@ const Merge = (props: BaseToolProps) => {
|
|||||||
|
|
||||||
// Custom file sorting logic for merge tool
|
// Custom file sorting logic for merge tool
|
||||||
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
|
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
|
||||||
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
|
const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
switch (sortType) {
|
switch (sortType) {
|
||||||
case 'filename':
|
case 'filename':
|
||||||
comparison = recordA.name.localeCompare(recordB.name);
|
comparison = stubA.name.localeCompare(stubB.name);
|
||||||
break;
|
break;
|
||||||
case 'dateModified':
|
case 'dateModified':
|
||||||
comparison = recordA.lastModified - recordB.lastModified;
|
comparison = stubA.lastModified - stubB.lastModified;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return ascending ? comparison : -comparison;
|
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));
|
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
|
||||||
reorderFiles([...selectedIds, ...deselectedIds]);
|
reorderFiles([...selectedIds, ...deselectedIds]);
|
||||||
}, [selectedRecords, fileIds, reorderFiles]);
|
}, [selectedFileStubs, fileIds, reorderFiles]);
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
|
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", "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;
|
@ -27,6 +27,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
|||||||
'/remove-password': 'remove-password',
|
'/remove-password': 'remove-password',
|
||||||
'/single-large-page': 'single-large-page',
|
'/single-large-page': 'single-large-page',
|
||||||
'/repair': 'repair',
|
'/repair': 'repair',
|
||||||
|
'/rotate-pdf': 'rotate',
|
||||||
'/unlock-pdf-forms': 'unlock-pdf-forms',
|
'/unlock-pdf-forms': 'unlock-pdf-forms',
|
||||||
'/remove-certificate-sign': 'remove-certificate-sign',
|
'/remove-certificate-sign': 'remove-certificate-sign',
|
||||||
'/remove-cert-sign': 'remove-certificate-sign'
|
'/remove-cert-sign': 'remove-certificate-sign'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user