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" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <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$/.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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -330,22 +334,6 @@
<workItem from="1740933006573" duration="3679000" /> <workItem from="1740933006573" duration="3679000" />
<workItem from="1741475969294" duration="4215000" /> <workItem from="1741475969294" duration="4215000" />
</task> </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"> <task id="LOCAL-00103" summary="feat: update readme">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1740276092528</created> <created>1740276092528</created>
@@ -722,7 +710,23 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1741423587662</updated> <updated>1741423587662</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -759,19 +763,7 @@
<map> <map>
<entry key="MAIN"> <entry key="MAIN">
<value> <value>
<State> <State />
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="origin/examples" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value> </value>
</entry> </entry>
</map> </map>
@@ -781,7 +773,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" /> <option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" /> <option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" /> <option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="docs: img" />
<MESSAGE value="fix: bg" /> <MESSAGE value="fix: bg" />
<MESSAGE value="chore: handle enter press on search" /> <MESSAGE value="chore: handle enter press on search" />
<MESSAGE value="chore: show tooloptions in example" /> <MESSAGE value="chore: show tooloptions in example" />
@@ -806,7 +797,8 @@
<MESSAGE value="style: tools height" /> <MESSAGE value="style: tools height" />
<MESSAGE value="chore: update meta" /> <MESSAGE value="chore: update meta" />
<MESSAGE value="feat: change pgn opacity" /> <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>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />

10
package-lock.json generated
View File

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

View File

@@ -47,6 +47,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"type-fest": "^4.35.0", "type-fest": "^4.35.0",
"yup": "^1.4.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 { Box } from '@mui/material';
import { FormikProps, FormikValues } from 'formik'; 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 ToolInputAndResult from '@components/ToolInputAndResult';
import ToolInfo from '@components/ToolInfo'; import ToolInfo from '@components/ToolInfo';
import Separator from '@components/Separator'; import Separator from '@components/Separator';
@@ -12,9 +15,14 @@ import { ToolComponentProps } from '@tools/defineTool';
interface ToolContentProps<T, I> extends ToolComponentProps { interface ToolContentProps<T, I> extends ToolComponentProps {
// Input/Output components // Input/Output components
inputComponent: ReactNode; inputComponent?: ReactNode;
resultComponent: ReactNode; resultComponent: ReactNode;
renderCustomInput?: (
values: T,
setFieldValue: (fieldName: string, value: any) => void
) => ReactNode;
// Tool options // Tool options
initialValues: T; initialValues: T;
getGroups: GetGroupsType<T> | null; getGroups: GetGroupsType<T> | null;
@@ -49,13 +57,31 @@ export default function ToolContent<T extends FormikValues, I>({
exampleCards, exampleCards,
input, input,
setInput, setInput,
validationSchema validationSchema,
renderCustomInput
}: ToolContentProps<T, I>) { }: ToolContentProps<T, I>) {
const formRef = useRef<FormikProps<T>>(null); const formRef = useRef<FormikProps<T>>(null);
const [initialized, forceUpdate] = useState(0);
useEffect(() => {
if (formRef.current && !initialized) {
forceUpdate((n) => n + 1);
}
}, [initialized]);
return ( return (
<Box> <Box>
<ToolInputAndResult input={inputComponent} result={resultComponent} /> <ToolInputAndResult
input={
inputComponent ??
(renderCustomInput &&
formRef.current &&
renderCustomInput(
formRef.current.values,
formRef.current.setFieldValue
))
}
result={resultComponent}
/>
<ToolOptions <ToolOptions
formRef={formRef} formRef={formRef}

View File

@@ -1,6 +1,8 @@
import { Box, useTheme } from '@mui/material'; import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { useContext, useEffect, useRef, useState } from 'react'; 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 InputHeader from '../InputHeader';
import InputFooter from './InputFooter'; import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
@@ -12,18 +14,57 @@ interface ToolFileInputProps {
onChange: (file: File) => void; onChange: (file: File) => void;
accept: string[]; accept: string[];
title?: 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({ export default function ToolFileInput({
value, value,
onChange, onChange,
accept, accept,
title = 'File' title = 'File',
showCropOverlay = false,
cropShape = 'rectangular',
cropPosition = { x: 0, y: 0 },
cropSize = { width: 100, height: 100 },
onCropChange
}: ToolFileInputProps) { }: ToolFileInputProps) {
const [preview, setPreview] = useState<string | null>(null); const [preview, setPreview] = useState<string | null>(null);
const theme = useTheme(); const theme = useTheme();
const { showSnackBar } = useContext(CustomSnackBarContext); const { showSnackBar } = useContext(CustomSnackBarContext);
const fileInputRef = useRef<HTMLInputElement>(null); 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 = () => { const handleCopy = () => {
if (value) { if (value) {
@@ -38,14 +79,16 @@ export default function ToolFileInput({
}); });
} }
}; };
const handlePaste = (event: ClipboardEvent) => { const handlePaste = (event: ClipboardEvent) => {
const clipboardItems = event.clipboardData?.items ?? []; const clipboardItems = event.clipboardData?.items ?? [];
const item = clipboardItems[0]; const item = clipboardItems[0];
if (item.type.includes('image')) { if (item && item.type.includes('image')) {
const file = item.getAsFile(); const file = item.getAsFile();
onChange(file!); if (file) onChange(file);
} }
}; };
useEffect(() => { useEffect(() => {
if (value) { if (value) {
const objectUrl = URL.createObjectURL(value); const objectUrl = URL.createObjectURL(value);
@@ -55,6 +98,8 @@ export default function ToolFileInput({
return () => URL.revokeObjectURL(objectUrl); return () => URL.revokeObjectURL(objectUrl);
} else { } else {
setPreview(null); setPreview(null);
setImgWidth(0);
setImgHeight(0);
} }
}, [value]); }, [value]);
@@ -62,10 +107,54 @@ export default function ToolFileInput({
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) onChange(file); if (file) onChange(file);
}; };
const handleImportClick = () => { const handleImportClick = () => {
fileInputRef.current?.click(); 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(() => { useEffect(() => {
window.addEventListener('paste', handlePaste); window.addEventListener('paste', handlePaste);
@@ -84,25 +173,48 @@ export default function ToolFileInput({
border: preview ? 0 : 1, border: preview ? 0 : 1,
borderRadius: 2, borderRadius: 2,
boxShadow: '5', boxShadow: '5',
bgcolor: 'white' bgcolor: 'white',
position: 'relative'
}} }}
> >
{preview ? ( {preview ? (
<Box <Box
width={'100%'} width="100%"
height={'100%'} height="100%"
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundImage: `url(${greyPattern})` backgroundImage: `url(${greyPattern})`,
position: 'relative',
overflow: 'hidden'
}} }}
> >
<img {showCropOverlay ? (
src={preview} <ReactCrop
alt="Preview" crop={crop}
style={{ maxWidth: '100%', maxHeight: globalInputHeight }} 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>
) : ( ) : (
<Box <Box

View File

@@ -3,7 +3,7 @@ import React, { useState } from 'react';
import * as Yup from 'yup'; import * as Yup from 'yup';
import ToolFileInput from '@components/input/ToolFileInput'; import ToolFileInput from '@components/input/ToolFileInput';
import ToolFileResult from '@components/result/ToolFileResult'; 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 TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent'; import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool'; import { ToolComponentProps } from '@tools/defineTool';
@@ -16,7 +16,7 @@ const initialValues = {
cropHeight: '100', cropHeight: '100',
cropShape: 'rectangular' as 'rectangular' | 'circular' cropShape: 'rectangular' as 'rectangular' | 'circular'
}; };
type InitialValuesType = typeof initialValues;
const validationSchema = Yup.object({ const validationSchema = Yup.object({
xPosition: Yup.number() xPosition: Yup.number()
.min(0, 'X position must be positive') .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 [input, setInput] = useState<File | null>(null);
const [result, setResult] = 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; if (!input) return;
const { xPosition, yPosition, cropWidth, cropHeight, cropShape } = const { xPosition, yPosition, cropWidth, cropHeight, cropShape } =
@@ -110,8 +110,19 @@ export default function CropPng({ title }: ToolComponentProps) {
processImage(input, x, y, width, height, isCircular); 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, values,
updateField 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 ( return (
<ToolContent <ToolContent
title={title} title={title}
@@ -191,14 +223,7 @@ export default function CropPng({ title }: ToolComponentProps) {
compute={compute} compute={compute}
input={input} input={input}
validationSchema={validationSchema} validationSchema={validationSchema}
inputComponent={ renderCustomInput={renderCustomInput}
<ToolFileInput
value={input}
onChange={setInput}
accept={['image/png']}
title={'Input PNG'}
/>
}
resultComponent={ resultComponent={
<ToolFileResult <ToolFileResult
title={'Cropped PNG'} title={'Cropped PNG'}