mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 05:59:34 +02:00
feat: crop png
This commit is contained in:
6
src/pages/tools/image/png/crop/crop.service.test.ts
Normal file
6
src/pages/tools/image/png/crop/crop.service.test.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { expect, describe, it } from 'vitest';
|
||||||
|
// import { } from './service';
|
||||||
|
//
|
||||||
|
// describe('crop', () => {
|
||||||
|
//
|
||||||
|
// })
|
216
src/pages/tools/image/png/crop/index.tsx
Normal file
216
src/pages/tools/image/png/crop/index.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import ToolFileInput from '@components/input/ToolFileInput';
|
||||||
|
import ToolFileResult from '@components/result/ToolFileResult';
|
||||||
|
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||||
|
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||||
|
import ToolContent from '@components/ToolContent';
|
||||||
|
import { ToolComponentProps } from '@tools/defineTool';
|
||||||
|
import SimpleRadio from '@components/options/SimpleRadio';
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
xPosition: '0',
|
||||||
|
yPosition: '0',
|
||||||
|
cropWidth: '100',
|
||||||
|
cropHeight: '100',
|
||||||
|
cropShape: 'rectangular' as 'rectangular' | 'circular'
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
xPosition: Yup.number()
|
||||||
|
.min(0, 'X position must be positive')
|
||||||
|
.required('X position is required'),
|
||||||
|
yPosition: Yup.number()
|
||||||
|
.min(0, 'Y position must be positive')
|
||||||
|
.required('Y position is required'),
|
||||||
|
cropWidth: Yup.number()
|
||||||
|
.min(1, 'Width must be at least 1px')
|
||||||
|
.required('Width is required'),
|
||||||
|
cropHeight: Yup.number()
|
||||||
|
.min(1, 'Height must be at least 1px')
|
||||||
|
.required('Height is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CropPng({ title }: ToolComponentProps) {
|
||||||
|
const [input, setInput] = useState<File | null>(null);
|
||||||
|
const [result, setResult] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const compute = (optionsValues: typeof initialValues, input: any) => {
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const { xPosition, yPosition, cropWidth, cropHeight, cropShape } =
|
||||||
|
optionsValues;
|
||||||
|
const x = parseInt(xPosition);
|
||||||
|
const y = parseInt(yPosition);
|
||||||
|
const width = parseInt(cropWidth);
|
||||||
|
const height = parseInt(cropHeight);
|
||||||
|
const isCircular = cropShape === 'circular';
|
||||||
|
|
||||||
|
const processImage = async (
|
||||||
|
file: File,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
isCircular: boolean
|
||||||
|
) => {
|
||||||
|
// Create source canvas
|
||||||
|
const sourceCanvas = document.createElement('canvas');
|
||||||
|
const sourceCtx = sourceCanvas.getContext('2d');
|
||||||
|
if (sourceCtx == null) return;
|
||||||
|
|
||||||
|
// Create destination canvas
|
||||||
|
const destCanvas = document.createElement('canvas');
|
||||||
|
const destCtx = destCanvas.getContext('2d');
|
||||||
|
if (destCtx == null) return;
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
const img = new Image();
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
await img.decode();
|
||||||
|
|
||||||
|
// Set source canvas dimensions
|
||||||
|
sourceCanvas.width = img.width;
|
||||||
|
sourceCanvas.height = img.height;
|
||||||
|
|
||||||
|
// Draw original image on source canvas
|
||||||
|
sourceCtx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Set destination canvas dimensions to crop size
|
||||||
|
destCanvas.width = width;
|
||||||
|
destCanvas.height = height;
|
||||||
|
|
||||||
|
if (isCircular) {
|
||||||
|
// For circular crop
|
||||||
|
destCtx.beginPath();
|
||||||
|
// Create a circle with center at half width/height and radius of half the smaller dimension
|
||||||
|
const radius = Math.min(width, height) / 2;
|
||||||
|
destCtx.arc(width / 2, height / 2, radius, 0, Math.PI * 2);
|
||||||
|
destCtx.closePath();
|
||||||
|
destCtx.clip();
|
||||||
|
|
||||||
|
// Draw the cropped portion centered in the circle
|
||||||
|
destCtx.drawImage(img, x, y, width, height, 0, 0, width, height);
|
||||||
|
} else {
|
||||||
|
// For rectangular crop, simply draw the specified region
|
||||||
|
destCtx.drawImage(img, x, y, width, height, 0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert canvas to blob and create file
|
||||||
|
destCanvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const newFile = new File([blob], file.name, {
|
||||||
|
type: 'image/png'
|
||||||
|
});
|
||||||
|
setResult(newFile);
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
processImage(input, x, y, width, height, isCircular);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroups: GetGroupsType<typeof initialValues> = ({
|
||||||
|
values,
|
||||||
|
updateField
|
||||||
|
}) => [
|
||||||
|
{
|
||||||
|
title: 'Crop Position and Size',
|
||||||
|
component: (
|
||||||
|
<Box>
|
||||||
|
<TextFieldWithDesc
|
||||||
|
value={values.xPosition}
|
||||||
|
onOwnChange={(val) => updateField('xPosition', val)}
|
||||||
|
description={'X position (in pixels)'}
|
||||||
|
inputProps={{
|
||||||
|
'data-testid': 'x-position-input',
|
||||||
|
type: 'number',
|
||||||
|
min: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextFieldWithDesc
|
||||||
|
value={values.yPosition}
|
||||||
|
onOwnChange={(val) => updateField('yPosition', val)}
|
||||||
|
description={'Y position (in pixels)'}
|
||||||
|
inputProps={{
|
||||||
|
'data-testid': 'y-position-input',
|
||||||
|
type: 'number',
|
||||||
|
min: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextFieldWithDesc
|
||||||
|
value={values.cropWidth}
|
||||||
|
onOwnChange={(val) => updateField('cropWidth', val)}
|
||||||
|
description={'Crop width (in pixels)'}
|
||||||
|
inputProps={{
|
||||||
|
'data-testid': 'crop-width-input',
|
||||||
|
type: 'number',
|
||||||
|
min: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextFieldWithDesc
|
||||||
|
value={values.cropHeight}
|
||||||
|
onOwnChange={(val) => updateField('cropHeight', val)}
|
||||||
|
description={'Crop height (in pixels)'}
|
||||||
|
inputProps={{
|
||||||
|
'data-testid': 'crop-height-input',
|
||||||
|
type: 'number',
|
||||||
|
min: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Crop Shape',
|
||||||
|
component: (
|
||||||
|
<Box>
|
||||||
|
<SimpleRadio
|
||||||
|
onClick={() => updateField('cropShape', 'rectangular')}
|
||||||
|
checked={values.cropShape == 'rectangular'}
|
||||||
|
description={'Crop a rectangular fragment from a PNG.'}
|
||||||
|
title={'Rectangular Crop Shape'}
|
||||||
|
/>
|
||||||
|
<SimpleRadio
|
||||||
|
onClick={() => updateField('cropShape', 'circular')}
|
||||||
|
checked={values.cropShape == 'circular'}
|
||||||
|
description={'Crop a circular fragment from a PNG.'}
|
||||||
|
title={'Circular Crop Shape'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolContent
|
||||||
|
title={title}
|
||||||
|
initialValues={initialValues}
|
||||||
|
getGroups={getGroups}
|
||||||
|
compute={compute}
|
||||||
|
input={input}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
inputComponent={
|
||||||
|
<ToolFileInput
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
accept={['image/png']}
|
||||||
|
title={'Input PNG'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
resultComponent={
|
||||||
|
<ToolFileResult
|
||||||
|
title={'Cropped PNG'}
|
||||||
|
value={result}
|
||||||
|
extension={'png'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
toolInfo={{
|
||||||
|
title: 'Crop PNG Image',
|
||||||
|
description:
|
||||||
|
'This tool allows you to crop a PNG image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
12
src/pages/tools/image/png/crop/meta.ts
Normal file
12
src/pages/tools/image/png/crop/meta.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineTool } from '@tools/defineTool';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const tool = defineTool('png', {
|
||||||
|
name: 'Crop',
|
||||||
|
path: 'crop',
|
||||||
|
icon: 'mdi:crop', // Iconify icon as a string
|
||||||
|
description: 'A tool to crop images with precision and ease.',
|
||||||
|
shortDescription: 'Crop images quickly.',
|
||||||
|
keywords: ['crop', 'image', 'edit', 'resize', 'trim'],
|
||||||
|
component: lazy(() => import('./index'))
|
||||||
|
});
|
0
src/pages/tools/image/png/crop/service.ts
Normal file
0
src/pages/tools/image/png/crop/service.ts
Normal file
Reference in New Issue
Block a user