diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index dc83e43..c28ff34 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,9 +4,13 @@
-
+
-
+
+
+
+
+
@@ -330,22 +334,6 @@
-
-
- 1720914810712
-
-
-
- 1720914810713
-
-
-
- 1740267666455
-
-
-
- 1740267666455
-
1740276092528
@@ -722,7 +710,23 @@
1741423587662
-
+
+
+ 1741487705292
+
+
+
+ 1741487705292
+
+
+
+ 1741487735223
+
+
+
+ 1741487735223
+
+
@@ -759,19 +763,7 @@
@@ -781,7 +773,6 @@
-
@@ -806,7 +797,8 @@
-
+
+
diff --git a/package-lock.json b/package-lock.json
index fbc5655..93905e8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 541b4af..e3f7b13 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/components/ToolContent.tsx b/src/components/ToolContent.tsx
index 4eb02fb..afd7ec5 100644
--- a/src/components/ToolContent.tsx
+++ b/src/components/ToolContent.tsx
@@ -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 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 | null;
@@ -49,13 +57,31 @@ export default function ToolContent({
exampleCards,
input,
setInput,
- validationSchema
+ validationSchema,
+ renderCustomInput
}: ToolContentProps) {
const formRef = useRef>(null);
+ const [initialized, forceUpdate] = useState(0);
+ useEffect(() => {
+ if (formRef.current && !initialized) {
+ forceUpdate((n) => n + 1);
+ }
+ }, [initialized]);
return (
-
+
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(null);
const theme = useTheme();
const { showSnackBar } = useContext(CustomSnackBarContext);
const fileInputRef = useRef(null);
+ const imageRef = useRef(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({
+ 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) => {
+ 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 ? (
-
+ {showCropOverlay ? (
+
+
+
+ ) : (
+
+ )}
) : (
(null);
const [result, setResult] = useState(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) =>
+ (
+ 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 = ({
+ const getGroups: GetGroupsType = ({
values,
updateField
}) => [
@@ -182,7 +193,28 @@ export default function CropPng({ title }: ToolComponentProps) {
)
}
];
-
+ const renderCustomInput = (
+ values: InitialValuesType,
+ updateField: UpdateField
+ ) => (
+
+ );
return (
- }
+ renderCustomInput={renderCustomInput}
resultComponent={