feat: crop png

This commit is contained in:
Ibrahima G. Coulibaly
2025-03-09 03:58:04 +00:00
parent 5e1aceccd1
commit 0f1956799c
6 changed files with 230 additions and 64 deletions

60
.idea/workspace.xml generated
View File

@@ -4,9 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: change pgn opacity">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: crop png">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolContent.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolContent.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/ToolFileInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/ToolFileInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/crop/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/crop/index.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -330,22 +334,6 @@
<workItem from="1740933006573" duration="3679000" />
<workItem from="1741475969294" duration="4215000" />
</task>
<task id="LOCAL-00101" summary="chore: use string tools">
<option name="closed" value="true" />
<created>1720914810712</created>
<option name="number" value="00101" />
<option name="presentableId" value="LOCAL-00101" />
<option name="project" value="LOCAL" />
<updated>1720914810713</updated>
</task>
<task id="LOCAL-00102" summary="fix: ctrl v">
<option name="closed" value="true" />
<created>1740267666455</created>
<option name="number" value="00102" />
<option name="presentableId" value="LOCAL-00102" />
<option name="project" value="LOCAL" />
<updated>1740267666455</updated>
</task>
<task id="LOCAL-00103" summary="feat: update readme">
<option name="closed" value="true" />
<created>1740276092528</created>
@@ -722,7 +710,23 @@
<option name="project" value="LOCAL" />
<updated>1741423587662</updated>
</task>
<option name="localTasksCounter" value="150" />
<task id="LOCAL-00150" summary="feat: crop png">
<option name="closed" value="true" />
<created>1741487705292</created>
<option name="number" value="00150" />
<option name="presentableId" value="LOCAL-00150" />
<option name="project" value="LOCAL" />
<updated>1741487705292</updated>
</task>
<task id="LOCAL-00151" summary="feat: crop png">
<option name="closed" value="true" />
<created>1741487735223</created>
<option name="number" value="00151" />
<option name="presentableId" value="LOCAL-00151" />
<option name="project" value="LOCAL" />
<updated>1741487735223</updated>
</task>
<option name="localTasksCounter" value="152" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -759,19 +763,7 @@
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="origin/examples" />
</list>
</value>
</entry>
</map>
</option>
</State>
<State />
</value>
</entry>
</map>
@@ -781,7 +773,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="docs: img" />
<MESSAGE value="fix: bg" />
<MESSAGE value="chore: handle enter press on search" />
<MESSAGE value="chore: show tooloptions in example" />
@@ -806,7 +797,8 @@
<MESSAGE value="style: tools height" />
<MESSAGE value="chore: update meta" />
<MESSAGE value="feat: change pgn opacity" />
<option name="LAST_COMMIT_MESSAGE" value="feat: change pgn opacity" />
<MESSAGE value="feat: crop png" />
<option name="LAST_COMMIT_MESSAGE" value="feat: crop png" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

10
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"type-fest": "^4.35.0",
"yup": "^1.4.0"
@@ -8551,6 +8552,15 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-image-crop": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.7.tgz",
"integrity": "sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

View File

@@ -47,6 +47,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"type-fest": "^4.35.0",
"yup": "^1.4.0"

View File

@@ -1,7 +1,10 @@
import React, { useRef, useState, ReactNode } from 'react';
import React, { useRef, useState, ReactNode, useEffect } from 'react';
import { Box } from '@mui/material';
import { FormikProps, FormikValues } from 'formik';
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
import ToolOptions, {
GetGroupsType,
UpdateField
} from '@components/options/ToolOptions';
import ToolInputAndResult from '@components/ToolInputAndResult';
import ToolInfo from '@components/ToolInfo';
import Separator from '@components/Separator';
@@ -12,9 +15,14 @@ import { ToolComponentProps } from '@tools/defineTool';
interface ToolContentProps<T, I> extends ToolComponentProps {
// Input/Output components
inputComponent: ReactNode;
inputComponent?: ReactNode;
resultComponent: ReactNode;
renderCustomInput?: (
values: T,
setFieldValue: (fieldName: string, value: any) => void
) => ReactNode;
// Tool options
initialValues: T;
getGroups: GetGroupsType<T> | null;
@@ -49,13 +57,31 @@ export default function ToolContent<T extends FormikValues, I>({
exampleCards,
input,
setInput,
validationSchema
validationSchema,
renderCustomInput
}: ToolContentProps<T, I>) {
const formRef = useRef<FormikProps<T>>(null);
const [initialized, forceUpdate] = useState(0);
useEffect(() => {
if (formRef.current && !initialized) {
forceUpdate((n) => n + 1);
}
}, [initialized]);
return (
<Box>
<ToolInputAndResult input={inputComponent} result={resultComponent} />
<ToolInputAndResult
input={
inputComponent ??
(renderCustomInput &&
formRef.current &&
renderCustomInput(
formRef.current.values,
formRef.current.setFieldValue
))
}
result={resultComponent}
/>
<ToolOptions
formRef={formRef}

View File

@@ -1,6 +1,8 @@
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import React, { useContext, useEffect, useRef, useState } from 'react';
import ReactCrop, { Crop, PixelCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
@@ -12,18 +14,57 @@ interface ToolFileInputProps {
onChange: (file: File) => void;
accept: string[];
title?: string;
showCropOverlay?: boolean;
cropShape?: 'rectangular' | 'circular';
cropPosition?: { x: number; y: number };
cropSize?: { width: number; height: number };
onCropChange?: (
position: { x: number; y: number },
size: { width: number; height: number }
) => void;
}
export default function ToolFileInput({
value,
onChange,
accept,
title = 'File'
title = 'File',
showCropOverlay = false,
cropShape = 'rectangular',
cropPosition = { x: 0, y: 0 },
cropSize = { width: 100, height: 100 },
onCropChange
}: ToolFileInputProps) {
const [preview, setPreview] = useState<string | null>(null);
const theme = useTheme();
const { showSnackBar } = useContext(CustomSnackBarContext);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [imgWidth, setImgWidth] = useState(0);
const [imgHeight, setImgHeight] = useState(0);
// Convert position and size to crop format used by ReactCrop
const [crop, setCrop] = useState<Crop>({
unit: 'px',
x: 0,
y: 0,
width: 0,
height: 0
});
const RATIO = imageRef.current ? imgWidth / imageRef.current.width : 1;
useEffect(() => {
if (imgWidth && imgHeight) {
setCrop({
unit: 'px',
x: cropPosition.x / RATIO,
y: cropPosition.y / RATIO,
width: cropSize.width / RATIO,
height: cropSize.height / RATIO
});
}
}, [cropPosition, cropSize, imgWidth, imgHeight]);
const handleCopy = () => {
if (value) {
@@ -38,14 +79,16 @@ export default function ToolFileInput({
});
}
};
const handlePaste = (event: ClipboardEvent) => {
const clipboardItems = event.clipboardData?.items ?? [];
const item = clipboardItems[0];
if (item.type.includes('image')) {
if (item && item.type.includes('image')) {
const file = item.getAsFile();
onChange(file!);
if (file) onChange(file);
}
};
useEffect(() => {
if (value) {
const objectUrl = URL.createObjectURL(value);
@@ -55,6 +98,8 @@ export default function ToolFileInput({
return () => URL.revokeObjectURL(objectUrl);
} else {
setPreview(null);
setImgWidth(0);
setImgHeight(0);
}
}, [value]);
@@ -62,10 +107,54 @@ export default function ToolFileInput({
const file = event.target.files?.[0];
if (file) onChange(file);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
// Handle image load to set dimensions
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
setImgWidth(width);
setImgHeight(height);
// Initialize crop with a centered default crop if needed
if (!crop.width && !crop.height && onCropChange) {
const initialCrop: Crop = {
unit: 'px',
x: Math.floor(width / 4),
y: Math.floor(height / 4),
width: Math.floor(width / 2),
height: Math.floor(height / 2)
};
setCrop(initialCrop);
// Notify parent component of initial crop
onCropChange(
{ x: initialCrop.x, y: initialCrop.y },
{ width: initialCrop.width, height: initialCrop.height }
);
}
};
// Handle crop changes from react-image-crop
const handleCropChange = (newCrop: Crop) => {
setCrop(newCrop);
};
const handleCropComplete = (crop: PixelCrop) => {
if (onCropChange) {
onCropChange(
{ x: Math.round(crop.x * RATIO), y: Math.round(crop.y * RATIO) },
{
width: Math.round(crop.width * RATIO),
height: Math.round(crop.height * RATIO)
}
);
}
};
useEffect(() => {
window.addEventListener('paste', handlePaste);
@@ -84,25 +173,48 @@ export default function ToolFileInput({
border: preview ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'white'
bgcolor: 'white',
position: 'relative'
}}
>
{preview ? (
<Box
width={'100%'}
height={'100%'}
width="100%"
height="100%"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${greyPattern})`
backgroundImage: `url(${greyPattern})`,
position: 'relative',
overflow: 'hidden'
}}
>
<img
src={preview}
alt="Preview"
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
/>
{showCropOverlay ? (
<ReactCrop
crop={crop}
onChange={handleCropChange}
onComplete={handleCropComplete}
circularCrop={cropShape === 'circular'}
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
>
<img
ref={imageRef}
src={preview}
alt="Preview"
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
onLoad={onImageLoad}
/>
</ReactCrop>
) : (
<img
ref={imageRef}
src={preview}
alt="Preview"
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
onLoad={onImageLoad}
/>
)}
</Box>
) : (
<Box

View File

@@ -3,7 +3,7 @@ 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 { GetGroupsType, UpdateField } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
@@ -16,7 +16,7 @@ const initialValues = {
cropHeight: '100',
cropShape: 'rectangular' as 'rectangular' | 'circular'
};
type InitialValuesType = typeof initialValues;
const validationSchema = Yup.object({
xPosition: Yup.number()
.min(0, 'X position must be positive')
@@ -36,7 +36,7 @@ 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) => {
const compute = (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
const { xPosition, yPosition, cropWidth, cropHeight, cropShape } =
@@ -110,8 +110,19 @@ export default function CropPng({ title }: ToolComponentProps) {
processImage(input, x, y, width, height, isCircular);
};
const handleCropChange =
(values: InitialValuesType, updateField: UpdateField<InitialValuesType>) =>
(
position: { x: number; y: number },
size: { width: number; height: number }
) => {
updateField('xPosition', position.x.toString());
updateField('yPosition', position.y.toString());
updateField('cropWidth', size.width.toString());
updateField('cropHeight', size.height.toString());
};
const getGroups: GetGroupsType<typeof initialValues> = ({
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
@@ -182,7 +193,28 @@ export default function CropPng({ title }: ToolComponentProps) {
)
}
];
const renderCustomInput = (
values: InitialValuesType,
updateField: UpdateField<InitialValuesType>
) => (
<ToolFileInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
showCropOverlay={!!input}
cropShape={values.cropShape as 'rectangular' | 'circular'}
cropPosition={{
x: parseInt(values.xPosition || '0'),
y: parseInt(values.yPosition || '0')
}}
cropSize={{
width: parseInt(values.cropWidth || '100'),
height: parseInt(values.cropHeight || '100')
}}
onCropChange={handleCropChange(values, updateField)}
/>
);
return (
<ToolContent
title={title}
@@ -191,14 +223,7 @@ export default function CropPng({ title }: ToolComponentProps) {
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolFileInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
/>
}
renderCustomInput={renderCustomInput}
resultComponent={
<ToolFileResult
title={'Cropped PNG'}