mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-18 21:49:31 +02:00
feat: crop png
This commit is contained in:
60
.idea/workspace.xml
generated
60
.idea/workspace.xml
generated
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||
|
@@ -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"
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
|
@@ -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'}
|
||||
|
Reference in New Issue
Block a user