feat: add rotate image tool (#108)

This commit is contained in:
liuzeyu 2025-07-03 17:07:38 +08:00
commit 3eaf8e41b0
6 changed files with 682 additions and 2 deletions

View file

@ -7,7 +7,7 @@ import { tool as changeOpacity } from './change-opacity/meta';
import { tool as createTransparent } from './create-transparent/meta';
import { tool as imageToText } from './image-to-text/meta';
import { tool as qrCodeGenerator } from './qr-code/meta';
import { tool as rotateImage } from './rotate/meta';
export const imageGenericTools = [
resizeImage,
compressImage,
@ -17,5 +17,6 @@ export const imageGenericTools = [
changeColors,
createTransparent,
imageToText,
qrCodeGenerator
qrCodeGenerator,
rotateImage
];

View file

@ -0,0 +1,138 @@
import { ToolComponentProps } from '@tools/defineTool';
import { InitialValuesType } from './type';
import * as Yup from 'yup';
import { useState } from 'react';
import { GetGroupsType } from '@components/options/ToolOptions';
import SimpleRadio from '@components/options/SimpleRadio';
import { Box } from '@mui/material';
import SelectWithDesc from '@components/options/SelectWithDesc';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { processImage } from './service';
const initialValues: InitialValuesType = {
rotateAngle: '0',
rotateMethod: 'Preset'
};
const validationSchema = Yup.object({
rotateAngle: Yup.number().when('rotateMethod', {
is: 'degrees',
then: (schema) =>
schema
.min(-360, 'Rotate angle must be at least -360')
.max(360, 'Rotate angle must be at most 360')
.required('Rotate angle is required')
})
});
export default function RotateImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const compute = async (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
setResult(await processImage(input, optionsValues));
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Rotate Method',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('rotateMethod', 'Preset')}
checked={values.rotateMethod === 'Preset'}
description={'Rotate by a specific angle in degrees.'}
title={'Preset angle'}
/>
<SimpleRadio
onClick={() => updateField('rotateMethod', 'Custom')}
checked={values.rotateMethod === 'Custom'}
description={'Rotate by a custom angle in degrees.'}
title={'Custom angle'}
/>
</Box>
)
},
...(values.rotateMethod === 'Preset'
? [
{
title: 'Preset angle',
component: (
<Box>
<SelectWithDesc
selected={values.rotateAngle}
onChange={(val) => updateField('rotateAngle', val)}
description={'Rotate by a specific angle in degrees.'}
options={[
{ label: '90 degrees', value: '90' },
{ label: '180 degrees', value: '180' },
{ label: '270 degrees', value: '270' },
{ label: 'Flip horizontally', value: 'flip-x' },
{ label: 'Flip vertically', value: 'flip-y' }
]}
/>
</Box>
)
}
]
: [
{
title: 'Custom angle',
component: (
<Box>
<TextFieldWithDesc
value={values.rotateAngle}
onOwnChange={(val) => updateField('rotateAngle', val)}
description={
'Rotate by a custom angle in degrees(from -360 to 360).'
}
inputProps={{
type: 'number',
min: -360,
max: 360
}}
/>
</Box>
)
}
])
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
title={'Input Image'}
accept={['image/jpeg', 'image/png', 'image/svg+xml', 'image/gif']}
/>
}
resultComponent={
<ToolFileResult
value={result}
title={'Rotated Image'}
extension={input?.name.split('.').pop() || 'png'}
/>
}
toolInfo={{
title: 'Rotate Image',
description:
'This tool allows you to rotate images by a specific angle in any degrees.'
}}
/>
);
}

View file

@ -0,0 +1,12 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Rotate Image',
path: 'rotate',
icon: 'mdi:rotate-clockwise',
description: 'Rotate an image by a specified angle.',
shortDescription: 'Rotate an image easily.',
keywords: ['rotate', 'image', 'angle', 'jpg', 'png'],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,97 @@
import { InitialValuesType } from './type';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
export const processImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
const { rotateAngle, rotateMethod } = options;
if (file.type === 'image/svg+xml') {
try {
// Read the SVG file
const fileText = await file.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(fileText, 'image/svg+xml');
const svgElement = svgDoc.documentElement as unknown as SVGSVGElement;
// Get current transform attribute or create new one
let currentTransform = svgElement.getAttribute('transform') || '';
// Calculate rotation angle
let angle = 0;
if (rotateMethod === 'Preset') {
if (rotateAngle === 'flip-x') {
currentTransform += ' scale(-1,1)';
} else if (rotateAngle === 'flip-y') {
currentTransform += ' scale(1,-1)';
} else {
angle = parseInt(rotateAngle);
}
} else {
angle = parseInt(rotateAngle);
}
// Add rotation if needed
if (angle !== 0) {
// Get SVG dimensions
const bbox = svgElement.getBBox();
const centerX = bbox.x + bbox.width / 2;
const centerY = bbox.y + bbox.height / 2;
currentTransform += ` rotate(${angle} ${centerX} ${centerY})`;
}
// Apply transform
svgElement.setAttribute('transform', currentTransform.trim());
// Convert back to file
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgDoc);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
return new File([blob], file.name, { type: 'image/svg+xml' });
} catch (error) {
console.error('Error processing SVG:', error);
return null;
}
}
// For non-SVG images, use FFmpeg
try {
const ffmpeg = new FFmpeg();
await ffmpeg.load();
// Write input file
await ffmpeg.writeFile('input', await fetchFile(file));
// Determine rotation command
let rotateCmd = '';
if (rotateMethod === 'Preset') {
if (rotateAngle === 'flip-x') {
rotateCmd = 'hflip';
} else if (rotateAngle === 'flip-y') {
rotateCmd = 'vflip';
} else {
rotateCmd = `rotate=${rotateAngle}*PI/180`;
}
} else {
rotateCmd = `rotate=${rotateAngle}*PI/180`;
}
// Execute FFmpeg command
await ffmpeg.exec([
'-i',
'input',
'-vf',
rotateCmd,
'output.' + file.name.split('.').pop()
]);
// Read the output file
const data = await ffmpeg.readFile('output.' + file.name.split('.').pop());
return new File([data], file.name, { type: file.type });
} catch (error) {
console.error('Error processing image:', error);
return null;
}
};

View file

@ -0,0 +1,4 @@
export type InitialValuesType = {
rotateAngle: string | 'flip-x' | 'flip-y'; // the angle to rotate the image
rotateMethod: 'Preset' | 'Custom';
};