Merge pull request #56 from iib0011/refactor-file-input

refactor: file inputs
This commit is contained in:
Ibrahima G. Coulibaly
2025-03-26 03:49:28 +00:00
committed by GitHub
12 changed files with 465 additions and 26 deletions

19
.idea/workspace.xml generated
View File

@@ -5,11 +5,18 @@
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: trim video">
<change afterPath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/components/input/ToolImageInput.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/components/input/ToolVideoInput.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/components/input/file-input-utils.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/meta.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/meta.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/change-colors-in-png/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/change-colors-in-png/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/change-opacity/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/change-opacity/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/compress-png/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/compress-png/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/convert-jgp-to-png/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/convert-jgp-to-png/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/create-transparent/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/create-transparent/index.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" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/trim/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/trim/index.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -26,7 +33,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="chesterkxng" />
<entry key="$PROJECT_DIR$" value="main" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -131,7 +138,7 @@
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run",
"git-widget-placeholder": "#53 on fork/ARRY7686/csvToXml",
"git-widget-placeholder": "refactor-file-input",
"ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/pages/tools/list/duplicate/index.tsx",

View File

@@ -0,0 +1,147 @@
import React, { ReactNode, useContext, useEffect } from 'react';
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import {
BaseFileInputProps,
createObjectURL,
revokeObjectURL
} from './file-input-utils';
import { globalInputHeight } from '../../config/uiConfig';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import greyPattern from '@assets/grey-pattern.png';
interface BaseFileInputComponentProps extends BaseFileInputProps {
children: (props: { preview: string | undefined }) => ReactNode;
type: 'image' | 'video' | 'audio';
}
export default function BaseFileInput({
value,
onChange,
accept,
title,
children,
type
}: BaseFileInputComponentProps) {
const [preview, setPreview] = React.useState<string | null>(null);
const theme = useTheme();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
useEffect(() => {
if (value) {
const objectUrl = createObjectURL(value);
setPreview(objectUrl);
return () => revokeObjectURL(objectUrl);
} else {
setPreview(null);
}
}, [value]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onChange(file);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleCopy = () => {
if (value) {
const blob = new Blob([value], { type: value.type });
const clipboardItem = new ClipboardItem({ [value.type]: blob });
navigator.clipboard
.write([clipboardItem])
.then(() => showSnackBar('File copied', 'success'))
.catch((err) => {
showSnackBar('Failed to copy: ' + err, 'error');
});
}
};
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const clipboardItems = event.clipboardData?.items ?? [];
const item = clipboardItems[0];
if (
item &&
(item.type.includes('image') || item.type.includes('video'))
) {
const file = item.getAsFile();
if (file) onChange(file);
}
};
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('paste', handlePaste);
};
}, [onChange]);
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
/>
<Box
sx={{
width: '100%',
height: globalInputHeight,
border: preview ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'white',
position: 'relative'
}}
>
{preview ? (
<Box
width="100%"
height="100%"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${greyPattern})`,
position: 'relative',
overflow: 'hidden'
}}
>
{children({ preview })}
</Box>
) : (
<Box
onClick={handleImportClick}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 5,
height: '100%',
cursor: 'pointer'
}}
>
<Typography color={theme.palette.grey['600']}>
Click here to select a {type} from your device, press Ctrl+V to
use a {type} from your clipboard, drag and drop a file from
desktop
</Typography>
</Box>
)}
</Box>
<InputFooter handleCopy={handleCopy} handleImport={handleImportClick} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
/>
</Box>
);
}

View File

@@ -0,0 +1,142 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box } from '@mui/material';
import ReactCrop, { Crop, PixelCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps } from './file-input-utils';
import { globalInputHeight } from '../../config/uiConfig';
interface ImageFileInputProps extends BaseFileInputProps {
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 ToolImageInput({
showCropOverlay = false,
cropShape = 'rectangular',
cropPosition = { x: 0, y: 0 },
cropSize = { width: 100, height: 100 },
onCropChange,
...props
}: ImageFileInputProps) {
const imageRef = useRef<HTMLImageElement>(null);
const [imgWidth, setImgWidth] = useState(0);
const [imgHeight, setImgHeight] = useState(0);
const [crop, setCrop] = useState<Crop>({
unit: 'px',
x: 0,
y: 0,
width: 0,
height: 0
});
const RATIO = imageRef.current ? imgWidth / imageRef.current.width : 1;
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
setImgWidth(width);
setImgHeight(height);
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);
onCropChange(
{ x: initialCrop.x, y: initialCrop.y },
{ width: initialCrop.width, height: initialCrop.height }
);
}
};
useEffect(() => {
if (
imgWidth &&
imgHeight &&
(cropPosition.x !== 0 ||
cropPosition.y !== 0 ||
cropSize.width !== 100 ||
cropSize.height !== 100)
) {
setCrop({
unit: 'px',
x: cropPosition.x / RATIO,
y: cropPosition.y / RATIO,
width: cropSize.width / RATIO,
height: cropSize.height / RATIO
});
}
}, [cropPosition, cropSize, imgWidth, imgHeight, RATIO]);
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)
}
);
}
};
return (
<BaseFileInput {...props} type={'image'}>
{({ preview }) => (
<Box
width="100%"
height="100%"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden'
}}
>
{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>
)}
</BaseFileInput>
);
}

View File

@@ -0,0 +1,121 @@
import React, { useRef, useState } from 'react';
import { Box, Typography } from '@mui/material';
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps, formatTime } from './file-input-utils';
interface VideoFileInputProps extends BaseFileInputProps {
showTrimControls?: boolean;
onTrimChange?: (trimStart: number, trimEnd: number) => void;
trimStart?: number;
trimEnd?: number;
}
export default function ToolVideoInput({
showTrimControls = false,
onTrimChange,
trimStart = 0,
trimEnd = 100,
...props
}: VideoFileInputProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoDuration, setVideoDuration] = useState(0);
const onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const duration = e.currentTarget.duration;
setVideoDuration(duration);
if (onTrimChange && trimStart === 0 && trimEnd === 100) {
onTrimChange(0, duration);
}
};
const handleTrimChange = (start: number, end: number) => {
if (onTrimChange) {
onTrimChange(start, end);
}
};
return (
<BaseFileInput {...props} type={'video'}>
{({ preview }) => (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}
>
<video
ref={videoRef}
src={preview}
style={{
maxWidth: '100%',
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
}}
onLoadedMetadata={onVideoLoad}
controls={!showTrimControls}
/>
{showTrimControls && videoDuration > 0 && (
<Box
sx={{
width: '100%',
padding: '10px 20px',
position: 'absolute',
bottom: 0,
left: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
display: 'flex',
flexDirection: 'column',
gap: 1
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Typography variant="caption">
Start: {formatTime(trimStart || 0)}
</Typography>
<Typography variant="caption">
End: {formatTime(trimEnd || videoDuration)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<div
className="range-slider-container"
style={{ margin: '20px 0', width: '100%' }}
>
<Slider
range
min={0}
max={videoDuration}
step={0.1}
value={[trimStart || 0, trimEnd || videoDuration]}
onChange={(values) => {
if (Array.isArray(values)) {
handleTrimChange(values[0], values[1]);
}
}}
allowCross={false}
pushable={0.1}
/>
</div>
</Box>
</Box>
)}
</Box>
)}
</BaseFileInput>
);
}

View File

@@ -0,0 +1,22 @@
export interface BaseFileInputProps {
value: File | null;
onChange: (file: File) => void;
accept: string[];
title?: string;
}
export const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
};
export const createObjectURL = (file: File): string => {
return URL.createObjectURL(file);
};
export const revokeObjectURL = (url: string): void => {
URL.revokeObjectURL(url);
};

View File

@@ -1,16 +1,15 @@
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 ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
import { GetGroupsType } from '@components/options/ToolOptions';
import ColorSelector from '@components/options/ColorSelector';
import Color from 'color';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import ToolInputAndResult from '@components/ToolInputAndResult';
import { areColorsSimilar } from 'utils/color';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolImageInput from '@components/input/ToolImageInput';
const initialValues = {
fromColor: 'white',
@@ -125,7 +124,7 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolFileInput
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import ToolFileInput from '@components/input/ToolFileInput';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { changeOpacity } from './service';
import ToolContent from '@components/ToolContent';
@@ -94,7 +94,7 @@ export default function ChangeOpacity({ title }: ToolComponentProps) {
title={title}
input={input}
inputComponent={
<ToolFileInput
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}

View File

@@ -1,7 +1,7 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolFileInput from '@components/input/ToolFileInput';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import imageCompression from 'browser-image-compression';
@@ -56,7 +56,7 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
title={title}
input={input}
inputComponent={
<ToolFileInput
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}

View File

@@ -1,5 +1,5 @@
import { Box } from '@mui/material';
import ToolFileInput from 'components/input/ToolFileInput';
import ToolImageInput from 'components/input/ToolImageInput';
import CheckboxWithDesc from 'components/options/CheckboxWithDesc';
import ColorSelector from 'components/options/ColorSelector';
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
@@ -101,7 +101,7 @@ export default function ConvertJgpToPng({ title }: ToolComponentProps) {
title={title}
input={input}
inputComponent={
<ToolFileInput
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/jpeg']}

View File

@@ -1,7 +1,7 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolFileInput from '@components/input/ToolFileInput';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import ColorSelector from '@components/options/ColorSelector';
import Color from 'color';
@@ -109,7 +109,7 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
<ToolContent
title={title}
inputComponent={
<ToolFileInput
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}

View File

@@ -1,7 +1,7 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolFileInput from '@components/input/ToolFileInput';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType, UpdateField } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
@@ -197,7 +197,7 @@ export default function CropPng({ title }: ToolComponentProps) {
values: InitialValuesType,
updateField: UpdateField<InitialValuesType>
) => (
<ToolFileInput
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/png']}

View File

@@ -1,7 +1,6 @@
import { Box } from '@mui/material';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import * as Yup from 'yup';
import ToolFileInput from '@components/input/ToolFileInput';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
@@ -11,6 +10,7 @@ import { updateNumberField } from '@utils/string';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { debounce } from 'lodash';
import ToolVideoInput from '@components/input/ToolVideoInput';
const ffmpeg = new FFmpeg();
@@ -35,14 +35,16 @@ export default function TrimVideo({ title }: ToolComponentProps) {
optionsValues: typeof initialValues,
input: File | null
) => {
console.log('compute', optionsValues, input);
if (!input) return;
const { trimStart, trimEnd } = optionsValues;
try {
if (!ffmpeg.loaded) {
await ffmpeg.load();
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp4';
@@ -111,12 +113,11 @@ export default function TrimVideo({ title }: ToolComponentProps) {
input={input}
renderCustomInput={({ trimStart, trimEnd }, setFieldValue) => {
return (
<ToolFileInput
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
type="video"
showTrimControls={true}
onTrimChange={(trimStart, trimEnd) => {
setFieldValue('trimStart', trimStart);