mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-20 14:39:34 +02:00
Merge branch 'iib0011:main' into feature/json2xml
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useContext } from 'react';
|
||||
import React, { ReactNode, useContext, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Formik, FormikValues, useFormikContext } from 'formik';
|
||||
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
|
||||
@@ -13,10 +13,12 @@ import { CustomSnackBarContext } from '../contexts/CustomSnackBarContext';
|
||||
|
||||
const FormikListenerComponent = <T,>({
|
||||
input,
|
||||
compute
|
||||
compute,
|
||||
onValuesChange
|
||||
}: {
|
||||
input: any;
|
||||
compute: (optionsValues: T, input: any) => void;
|
||||
onValuesChange?: (values: T) => void;
|
||||
}) => {
|
||||
const { values } = useFormikContext<T>();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
@@ -30,40 +32,31 @@ const FormikListenerComponent = <T,>({
|
||||
}
|
||||
}, [values, input, showSnackBar]);
|
||||
|
||||
useEffect(() => {
|
||||
onValuesChange?.(values);
|
||||
}, [onValuesChange, values]);
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
interface ToolContentProps<T, I> extends ToolComponentProps {
|
||||
// Input/Output components
|
||||
inputComponent?: ReactNode;
|
||||
resultComponent: ReactNode;
|
||||
|
||||
renderCustomInput?: (
|
||||
values: T,
|
||||
setFieldValue: (fieldName: string, value: any) => void
|
||||
) => ReactNode;
|
||||
|
||||
// Tool options
|
||||
initialValues: T;
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
|
||||
// Computation function
|
||||
compute: (optionsValues: T, input: I) => void;
|
||||
|
||||
// Tool info (optional)
|
||||
toolInfo?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
// Input value to pass to the compute function
|
||||
input?: I;
|
||||
|
||||
exampleCards?: CardExampleType<T>[];
|
||||
setInput?: React.Dispatch<React.SetStateAction<I>>;
|
||||
|
||||
// Validation schema (optional)
|
||||
validationSchema?: any;
|
||||
onValuesChange?: (values: T) => void;
|
||||
}
|
||||
|
||||
export default function ToolContent<T extends FormikValues, I>({
|
||||
@@ -78,7 +71,8 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
input,
|
||||
setInput,
|
||||
validationSchema,
|
||||
renderCustomInput
|
||||
renderCustomInput,
|
||||
onValuesChange
|
||||
}: ToolContentProps<T, I>) {
|
||||
return (
|
||||
<Box>
|
||||
@@ -98,7 +92,11 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
}
|
||||
result={resultComponent}
|
||||
/>
|
||||
<FormikListenerComponent<T> compute={compute} input={input} />
|
||||
<FormikListenerComponent<T>
|
||||
compute={compute}
|
||||
input={input}
|
||||
onValuesChange={onValuesChange}
|
||||
/>
|
||||
<ToolOptions getGroups={getGroups} />
|
||||
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
|
147
src/components/input/BaseFileInput.tsx
Normal file
147
src/components/input/BaseFileInput.tsx
Normal 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' | 'pdf';
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
142
src/components/input/ToolImageInput.tsx
Normal file
142
src/components/input/ToolImageInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
src/components/input/ToolPdfInput.tsx
Normal file
23
src/components/input/ToolPdfInput.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { useRef } from 'react';
|
||||
import BaseFileInput from './BaseFileInput';
|
||||
import { BaseFileInputProps } from './file-input-utils';
|
||||
|
||||
interface PdfFileInputProps extends BaseFileInputProps {}
|
||||
|
||||
export default function ToolPdfInput({ ...props }: PdfFileInputProps) {
|
||||
const pdfRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
return (
|
||||
<BaseFileInput {...props} type={'pdf'}>
|
||||
{({ preview }) => (
|
||||
<iframe
|
||||
ref={pdfRef}
|
||||
src={preview}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
</BaseFileInput>
|
||||
);
|
||||
}
|
121
src/components/input/ToolVideoInput.tsx
Normal file
121
src/components/input/ToolVideoInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/components/input/file-input-utils.ts
Normal file
22
src/components/input/file-input-utils.ts
Normal 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);
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import React, { useContext } from 'react';
|
||||
import InputHeader from '../InputHeader';
|
||||
import greyPattern from '@assets/grey-pattern.png';
|
||||
@@ -9,11 +9,15 @@ import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
export default function ToolFileResult({
|
||||
title = 'Result',
|
||||
value,
|
||||
extension
|
||||
extension,
|
||||
loading,
|
||||
loadingText
|
||||
}: {
|
||||
title?: string;
|
||||
value: File | null;
|
||||
extension: string;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}) {
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
@@ -59,12 +63,14 @@ export default function ToolFileResult({
|
||||
}
|
||||
};
|
||||
|
||||
type SupportedFileType = 'image' | 'video' | 'audio' | 'pdf' | 'unknown';
|
||||
// Determine the file type based on MIME type
|
||||
const getFileType = () => {
|
||||
const getFileType = (): SupportedFileType => {
|
||||
if (!value) return 'unknown';
|
||||
if (value.type.startsWith('image/')) return 'image';
|
||||
if (value.type.startsWith('video/')) return 'video';
|
||||
if (value.type.startsWith('audio/')) return 'audio';
|
||||
if (value.type.startsWith('application/pdf')) return 'pdf';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
@@ -83,44 +89,70 @@ export default function ToolFileResult({
|
||||
bgcolor: 'white'
|
||||
}}
|
||||
>
|
||||
{preview && (
|
||||
{loading ? (
|
||||
<Box
|
||||
width={'100%'}
|
||||
height={'100%'}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `url(${greyPattern})`
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{fileType === 'image' && (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Result"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'video' && (
|
||||
<video
|
||||
src={preview}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'unknown' && (
|
||||
<Box sx={{ padding: 2, textAlign: 'center' }}>
|
||||
File processed successfully. Click download to save the result.
|
||||
</Box>
|
||||
)}
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
{loadingText}... This may take a moment.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
preview && (
|
||||
<Box
|
||||
width={'100%'}
|
||||
height={'100%'}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `url(${greyPattern})`
|
||||
}}
|
||||
>
|
||||
{fileType === 'image' && (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Result"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'video' && (
|
||||
<video
|
||||
src={preview}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'pdf' && (
|
||||
<iframe
|
||||
src={preview}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
{fileType === 'unknown' && (
|
||||
<Box sx={{ padding: 2, textAlign: 'center' }}>
|
||||
File processed successfully. Click download to save the
|
||||
result.
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<ResultFooter
|
||||
|
@@ -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']}
|
||||
|
@@ -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']}
|
||||
|
@@ -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']}
|
||||
|
@@ -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']}
|
||||
|
@@ -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']}
|
||||
|
@@ -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']}
|
||||
|
@@ -4,6 +4,7 @@ import { tool as convertJgpToPng } from './convert-jgp-to-png/meta';
|
||||
import { tool as pngCreateTransparent } from './create-transparent/meta';
|
||||
import { tool as changeColorsInPng } from './change-colors-in-png/meta';
|
||||
import { tool as changeOpacity } from './change-opacity/meta';
|
||||
import { tool as removeBackground } from './remove-background/meta';
|
||||
|
||||
export const pngTools = [
|
||||
pngCompressPng,
|
||||
@@ -11,5 +12,6 @@ export const pngTools = [
|
||||
changeColorsInPng,
|
||||
convertJgpToPng,
|
||||
changeOpacity,
|
||||
pngCrop
|
||||
pngCrop,
|
||||
removeBackground
|
||||
];
|
||||
|
87
src/pages/tools/image/png/remove-background/index.tsx
Normal file
87
src/pages/tools/image/png/remove-background/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import { removeBackground } from '@imgly/background-removal';
|
||||
|
||||
const initialValues = {};
|
||||
|
||||
const validationSchema = Yup.object({});
|
||||
|
||||
export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
const compute = async (_optionsValues: typeof initialValues, input: any) => {
|
||||
if (!input) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Convert the input file to a Blob URL
|
||||
const inputUrl = URL.createObjectURL(input);
|
||||
|
||||
// Process the image with the background removal library
|
||||
const blob = await removeBackground(inputUrl, {
|
||||
progress: (progress) => {
|
||||
console.log(`Background removal progress: ${progress}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new file from the blob
|
||||
const newFile = new File(
|
||||
[blob],
|
||||
input.name.replace(/\.[^/.]+$/, '') + '-no-bg.png',
|
||||
{
|
||||
type: 'image/png'
|
||||
}
|
||||
);
|
||||
|
||||
setResult(newFile);
|
||||
} catch (err) {
|
||||
console.error('Error removing background:', err);
|
||||
throw new Error(
|
||||
'Failed to remove background. Please try a different image or try again later.'
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
initialValues={initialValues}
|
||||
getGroups={null}
|
||||
compute={compute}
|
||||
input={input}
|
||||
validationSchema={validationSchema}
|
||||
inputComponent={
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/png', 'image/jpeg', 'image/jpg']}
|
||||
title={'Input Image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Transparent PNG'}
|
||||
value={result}
|
||||
extension={'png'}
|
||||
loading={isProcessing}
|
||||
loadingText={'Removing background'}
|
||||
/>
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'Remove Background from PNG',
|
||||
description:
|
||||
'This tool uses AI to automatically remove the background from your images, creating a transparent PNG. Perfect for product photos, profile pictures, and design assets.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/image/png/remove-background/meta.ts
Normal file
13
src/pages/tools/image/png/remove-background/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
name: 'Remove Background from PNG',
|
||||
path: 'remove-background',
|
||||
icon: 'mdi:image-remove',
|
||||
description:
|
||||
"World's simplest online tool to remove backgrounds from PNG images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
|
||||
shortDescription: 'Automatically remove backgrounds from images',
|
||||
keywords: ['remove', 'background', 'png', 'transparent', 'image', 'ai'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
4
src/pages/tools/pdf/index.ts
Normal file
4
src/pages/tools/pdf/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { meta as splitPdfMeta } from './split-pdf/meta';
|
||||
import { DefinedTool } from '@tools/defineTool';
|
||||
|
||||
export const pdfTools: DefinedTool[] = [splitPdfMeta];
|
181
src/pages/tools/pdf/split-pdf/index.tsx
Normal file
181
src/pages/tools/pdf/split-pdf/index.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ToolFileInput from '@components/input/ToolFileInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { parsePageRanges, splitPdf } from './service';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { FormikProps } from 'formik';
|
||||
import ToolPdfInput from '@components/input/ToolPdfInput';
|
||||
|
||||
type InitialValuesType = {
|
||||
pageRanges: string;
|
||||
};
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
pageRanges: ''
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Extract Specific Pages',
|
||||
description: 'Extract pages 1, 5, 6, 7, and 8 from a PDF document.',
|
||||
sampleText: '',
|
||||
sampleResult: '',
|
||||
sampleOptions: {
|
||||
pageRanges: '1,5-8'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Extract First and Last Pages',
|
||||
description: 'Extract only the first and last pages from a PDF document.',
|
||||
sampleText: '',
|
||||
sampleResult: '',
|
||||
sampleOptions: {
|
||||
pageRanges: '1,10'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Extract a Range of Pages',
|
||||
description: 'Extract a continuous range of pages from a PDF document.',
|
||||
sampleText: '',
|
||||
sampleResult: '',
|
||||
sampleOptions: {
|
||||
pageRanges: '3-7'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function SplitPdf({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [totalPages, setTotalPages] = useState<number>(0);
|
||||
const [pageRangePreview, setPageRangePreview] = useState<string>('');
|
||||
|
||||
// Get the total number of pages when a PDF is uploaded
|
||||
useEffect(() => {
|
||||
const getPdfInfo = async () => {
|
||||
if (!input) {
|
||||
setTotalPages(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await input.arrayBuffer();
|
||||
const pdf = await PDFDocument.load(arrayBuffer);
|
||||
setTotalPages(pdf.getPageCount());
|
||||
} catch (error) {
|
||||
console.error('Error getting PDF info:', error);
|
||||
setTotalPages(0);
|
||||
}
|
||||
};
|
||||
|
||||
getPdfInfo();
|
||||
}, [input]);
|
||||
|
||||
const onValuesChange = (values: InitialValuesType) => {
|
||||
const { pageRanges } = values;
|
||||
if (!totalPages || !pageRanges?.trim()) {
|
||||
setPageRangePreview('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const count = parsePageRanges(pageRanges, totalPages).length;
|
||||
setPageRangePreview(
|
||||
`${count} page${count !== 1 ? 's' : ''} will be extracted`
|
||||
);
|
||||
} catch (error) {
|
||||
setPageRangePreview('');
|
||||
}
|
||||
};
|
||||
|
||||
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const splitResult = await splitPdf(input, values.pageRanges);
|
||||
setResult(splitResult);
|
||||
} catch (error) {
|
||||
throw new Error('Error splitting PDF:' + error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
initialValues={initialValues}
|
||||
compute={compute}
|
||||
exampleCards={exampleCards}
|
||||
inputComponent={
|
||||
<ToolPdfInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['application/pdf']}
|
||||
title={'Input PDF'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Output PDF with selected pages'}
|
||||
value={result}
|
||||
extension={'pdf'}
|
||||
loading={isProcessing}
|
||||
loadingText={'Extracting pages'}
|
||||
/>
|
||||
}
|
||||
getGroups={({ values, updateField }) => [
|
||||
{
|
||||
title: 'Page Selection',
|
||||
component: (
|
||||
<Box>
|
||||
{totalPages > 0 && (
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
PDF has {totalPages} page{totalPages !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<TextFieldWithDesc
|
||||
value={values.pageRanges}
|
||||
onOwnChange={(val) => {
|
||||
updateField('pageRanges', val);
|
||||
}}
|
||||
description={
|
||||
'Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)'
|
||||
}
|
||||
placeholder={'e.g., 1,5-8'}
|
||||
/>
|
||||
{pageRangePreview && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 1, color: 'primary.main' }}
|
||||
>
|
||||
{pageRangePreview}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]}
|
||||
onValuesChange={onValuesChange}
|
||||
toolInfo={{
|
||||
title: 'How to Use the Split PDF Tool',
|
||||
description: `This tool allows you to extract specific pages from a PDF document. You can specify individual page numbers (e.g., 1,3,5) or page ranges (e.g., 2-6) or a combination of both (e.g., 1,3-5,8).
|
||||
|
||||
Leave the page ranges field empty to include all pages from the PDF.
|
||||
|
||||
Examples:
|
||||
- "1,5,9" extracts pages 1, 5, and 9
|
||||
- "1-5" extracts pages 1 through 5
|
||||
- "1,3-5,8-10" extracts pages 1, 3, 4, 5, 8, 9, and 10`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/pdf/split-pdf/meta.ts
Normal file
13
src/pages/tools/pdf/split-pdf/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const meta = defineTool('pdf', {
|
||||
name: 'Split PDF',
|
||||
shortDescription: 'Extract specific pages from a PDF file',
|
||||
description:
|
||||
'Extract specific pages from a PDF file using page numbers or ranges (e.g., 1,5-8)',
|
||||
icon: 'mdi:file-pdf-box',
|
||||
component: lazy(() => import('./index')),
|
||||
keywords: ['pdf', 'split', 'extract', 'pages', 'range', 'document'],
|
||||
path: 'split-pdf'
|
||||
});
|
43
src/pages/tools/pdf/split-pdf/service.test.ts
Normal file
43
src/pages/tools/pdf/split-pdf/service.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { parsePageRanges } from './service';
|
||||
|
||||
describe('parsePageRanges', () => {
|
||||
test('should return all pages when input is empty', () => {
|
||||
expect(parsePageRanges('', 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('should parse single page numbers', () => {
|
||||
expect(parsePageRanges('1,3,5', 5)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
test('should parse page ranges', () => {
|
||||
expect(parsePageRanges('2-4', 5)).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
test('should parse mixed page numbers and ranges', () => {
|
||||
expect(parsePageRanges('1,3-5', 5)).toEqual([1, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('should handle whitespace', () => {
|
||||
expect(parsePageRanges(' 1, 3 - 5 ', 5)).toEqual([1, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('should ignore invalid page numbers', () => {
|
||||
expect(parsePageRanges('1,a,3', 5)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test('should ignore out-of-range page numbers', () => {
|
||||
expect(parsePageRanges('1,6,3', 5)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test('should limit ranges to valid pages', () => {
|
||||
expect(parsePageRanges('0-6', 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('should handle reversed ranges', () => {
|
||||
expect(parsePageRanges('4-2', 5)).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
test('should remove duplicates', () => {
|
||||
expect(parsePageRanges('1,1,2,2-4,3', 5)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
74
src/pages/tools/pdf/split-pdf/service.ts
Normal file
74
src/pages/tools/pdf/split-pdf/service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
/**
|
||||
* Parses a page range string and returns an array of page numbers
|
||||
* @param pageRangeStr String like "1,3-5,7" to extract pages 1, 3, 4, 5, and 7
|
||||
* @param totalPages Total number of pages in the PDF
|
||||
* @returns Array of page numbers to extract
|
||||
*/
|
||||
export function parsePageRanges(
|
||||
pageRangeStr: string,
|
||||
totalPages: number
|
||||
): number[] {
|
||||
if (!pageRangeStr.trim()) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pageNumbers = new Set<number>();
|
||||
const ranges = pageRangeStr.split(',');
|
||||
|
||||
for (const range of ranges) {
|
||||
const trimmedRange = range.trim();
|
||||
|
||||
if (trimmedRange.includes('-')) {
|
||||
const [start, end] = trimmedRange.split('-').map(Number);
|
||||
if (!isNaN(start) && !isNaN(end)) {
|
||||
// Handle both forward and reversed ranges
|
||||
const normalizedStart = Math.min(start, end);
|
||||
const normalizedEnd = Math.max(start, end);
|
||||
|
||||
for (
|
||||
let i = Math.max(1, normalizedStart);
|
||||
i <= Math.min(totalPages, normalizedEnd);
|
||||
i++
|
||||
) {
|
||||
pageNumbers.add(i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(trimmedRange, 10);
|
||||
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
|
||||
pageNumbers.add(pageNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...pageNumbers].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a PDF file based on specified page ranges
|
||||
* @param pdfFile The input PDF file
|
||||
* @param pageRanges String specifying which pages to extract (e.g., "1,3-5,7")
|
||||
* @returns Promise resolving to a new PDF file with only the selected pages
|
||||
*/
|
||||
export async function splitPdf(
|
||||
pdfFile: File,
|
||||
pageRanges: string
|
||||
): Promise<File> {
|
||||
const arrayBuffer = await pdfFile.arrayBuffer();
|
||||
const sourcePdf = await PDFDocument.load(arrayBuffer);
|
||||
const totalPages = sourcePdf.getPageCount();
|
||||
const pagesToExtract = parsePageRanges(pageRanges, totalPages);
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
sourcePdf,
|
||||
pagesToExtract.map((pageNum) => pageNum - 1)
|
||||
);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
|
||||
const newPdfBytes = await newPdf.save();
|
||||
const newFileName = pdfFile.name.replace('.pdf', '-extracted.pdf');
|
||||
return new File([newPdfBytes], newFileName, { type: 'application/pdf' });
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { convertHoursToDays } from './service';
|
||||
|
||||
describe('convertHoursToDays', () => {
|
||||
it('should convert hours to days with default accuracy', () => {
|
||||
const input = '48';
|
||||
const result = convertHoursToDays(input, '1', false);
|
||||
expect(result).toBe('2');
|
||||
});
|
||||
|
||||
it('should convert hours to days with specified accuracy', () => {
|
||||
const input = '50';
|
||||
const result = convertHoursToDays(input, '2', false);
|
||||
expect(result).toBe('2.08');
|
||||
});
|
||||
|
||||
it('should append "days" postfix when daysFlag is true', () => {
|
||||
const input = '72';
|
||||
const result = convertHoursToDays(input, '1', true);
|
||||
expect(result).toBe('3 days');
|
||||
});
|
||||
|
||||
it('should handle multiple lines of input', () => {
|
||||
const input = '24\n48\n72';
|
||||
const result = convertHoursToDays(input, '1', true);
|
||||
expect(result).toBe('1 days\n2 days\n3 days');
|
||||
});
|
||||
|
||||
it('should handle invalid input gracefully', () => {
|
||||
const input = 'abc';
|
||||
const result = convertHoursToDays(input, '1', false);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const input = '';
|
||||
const result = convertHoursToDays(input, '1', false);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
136
src/pages/tools/time/convert-hours-to-days/index.tsx
Normal file
136
src/pages/tools/time/convert-hours-to-days/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolTextInput from '@components/input/ToolTextInput';
|
||||
import ToolTextResult from '@components/result/ToolTextResult';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { convertHoursToDays } from './service';
|
||||
|
||||
const initialValues = {
|
||||
daysFlag: false,
|
||||
accuracy: '1'
|
||||
};
|
||||
type InitialValuesType = typeof initialValues;
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Hours to Integer Days',
|
||||
description:
|
||||
'In this example, we convert ten hour values to ten day values. Each input hour is divisible by 24 without a remainder, so all converted output values are full days. To better communicate the time units, we use the word "hours" in the input data and also add the word "days" to the output data.',
|
||||
sampleText: `24 hours
|
||||
48 hours
|
||||
72 hours
|
||||
96 hours
|
||||
120 hours
|
||||
144 hours
|
||||
168 hours
|
||||
192 hours
|
||||
216 hours
|
||||
240 hours`,
|
||||
sampleResult: `1 day
|
||||
2 days
|
||||
3 days
|
||||
4 days
|
||||
5 days
|
||||
6 days
|
||||
7 days
|
||||
8 days
|
||||
9 days
|
||||
10 days`,
|
||||
sampleOptions: { daysFlag: true, accuracy: '2' }
|
||||
},
|
||||
{
|
||||
title: 'Decimal Days',
|
||||
description:
|
||||
'In this example, we convert five decimal fraction day values to hours. Conversion of partial days is similar to the conversion of full days – they are all multiplied by 24. We turn off the option that appends the "hours" string after the converted values and get only the numerical hour values in the output.',
|
||||
sampleText: `1 hr
|
||||
100 hr
|
||||
9999 hr
|
||||
12345 hr
|
||||
333333 hr`,
|
||||
sampleResult: `0.0417 days
|
||||
4.1667 days
|
||||
416.625 days
|
||||
514.375 days
|
||||
13888.875 days`,
|
||||
sampleOptions: { daysFlag: true, accuracy: '4' }
|
||||
},
|
||||
{
|
||||
title: 'Partial Hours',
|
||||
description:
|
||||
'In the modern Gregorian calendar, a common year has 365 days and a leap year has 366 days. This makes the true average length of a year to be 365.242199 days. In this example, we load this number in the input field and convert it to the hours. It turns out that there 8765.812776 hours in an average year.',
|
||||
sampleText: `0.5
|
||||
0.01
|
||||
0.99`,
|
||||
sampleResult: `0.02083333
|
||||
0.00041667
|
||||
0.04125`,
|
||||
sampleOptions: { daysFlag: false, accuracy: '8' }
|
||||
}
|
||||
];
|
||||
|
||||
export default function ConvertDaysToHours({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (optionsValues: typeof initialValues, input: string) => {
|
||||
setResult(
|
||||
convertHoursToDays(input, optionsValues.accuracy, optionsValues.daysFlag)
|
||||
);
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Day Value Accuracy',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
description={
|
||||
'If the calculated days is a decimal number, then how many digits should be left after the decimal point?.'
|
||||
}
|
||||
value={values.accuracy}
|
||||
onOwnChange={(val) => updateField('accuracy', val)}
|
||||
type={'text'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Days Postfix',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
onChange={(val) => updateField('daysFlag', val)}
|
||||
checked={values.daysFlag}
|
||||
title={'Append Days Postfix'}
|
||||
description={'Display numeric day values with the postfix "days".'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={<ToolTextInput value={input} onChange={setInput} />}
|
||||
resultComponent={<ToolTextResult value={result} />}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
exampleCards={exampleCards}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/time/convert-hours-to-days/meta.ts
Normal file
15
src/pages/tools/time/convert-hours-to-days/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('time', {
|
||||
path: 'convert-hours-to-days',
|
||||
name: 'Convert Hours to Days',
|
||||
icon: 'mdi:hours-24',
|
||||
description:
|
||||
'With this browser-based application, you can calculate how many days there are in the given number of hours. Given one or more hour values in the input, it converts them into days via the simple math formula: days = hours/24. It works with arbitrary large hour values and you can also customize the decimal day precision.',
|
||||
shortDescription: 'Convert hours to days easily.',
|
||||
keywords: ['convert', 'hours', 'days'],
|
||||
longDescription:
|
||||
"This is a quick online utility for converting hours to days. To figure out the number of days in the specified hours, the program divides them by 24. For example, if the input hours value is 48, then by doing 48/24, it finds that there are 2 days, or if the hours value is 120, then it's 120/24 = 5 days. If the hours value is not divisible by 24, then the number of days is displayed as a decimal number. For example, 36 hours is 36/24 = 1.5 days and 100 hours is approximately 4.167 days. You can specify the precision of the decimal fraction calculation in the options. You can also enable the option that adds the postfix 'days' to all the output values. Timeabulous!",
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
33
src/pages/tools/time/convert-hours-to-days/service.ts
Normal file
33
src/pages/tools/time/convert-hours-to-days/service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { containsOnlyDigits } from '@utils/string';
|
||||
|
||||
function compute(input: string, accuracy: number) {
|
||||
if (!containsOnlyDigits(input)) {
|
||||
return '';
|
||||
}
|
||||
const hours = parseFloat(input);
|
||||
const days = (hours / 24).toFixed(accuracy);
|
||||
return parseFloat(days);
|
||||
}
|
||||
|
||||
export function convertHoursToDays(
|
||||
input: string,
|
||||
accuracy: string,
|
||||
daysFlag: boolean
|
||||
): string {
|
||||
if (!containsOnlyDigits(accuracy)) {
|
||||
throw new Error('Accuracy contains non digits.');
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
const lines = input.split('\n');
|
||||
|
||||
lines.forEach((line) => {
|
||||
const parts = line.split(' ');
|
||||
const hours = parts[0]; // Extract the number before the space
|
||||
const days = compute(hours, Number(accuracy));
|
||||
result.push(daysFlag ? `${days} days` : `${days}`);
|
||||
});
|
||||
|
||||
return result.join('\n');
|
||||
}
|
@@ -1,3 +1,4 @@
|
||||
import { tool as daysDoHours } from './convert-days-to-hours/meta';
|
||||
import { tool as hoursToDays } from './convert-hours-to-days/meta';
|
||||
|
||||
export const timeTools = [daysDoHours];
|
||||
export const timeTools = [daysDoHours, hoursToDays];
|
||||
|
@@ -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);
|
||||
|
@@ -22,7 +22,8 @@ export type ToolCategory =
|
||||
| 'list'
|
||||
| 'json'
|
||||
| 'csv'
|
||||
| 'time';
|
||||
| 'time'
|
||||
| 'pdf';
|
||||
|
||||
export interface DefinedTool {
|
||||
type: ToolCategory;
|
||||
|
@@ -10,6 +10,7 @@ import { jsonTools } from '../pages/tools/json';
|
||||
import { csvTools } from '../pages/tools/csv';
|
||||
import { timeTools } from '../pages/tools/time';
|
||||
import { IconifyIcon } from '@iconify/react';
|
||||
import { pdfTools } from '../pages/tools/pdf';
|
||||
|
||||
export const tools: DefinedTool[] = [
|
||||
...imageTools,
|
||||
@@ -19,7 +20,8 @@ export const tools: DefinedTool[] = [
|
||||
...csvTools,
|
||||
...videoTools,
|
||||
...numberTools,
|
||||
...timeTools
|
||||
...timeTools,
|
||||
...pdfTools
|
||||
];
|
||||
const categoriesConfig: {
|
||||
type: ToolCategory;
|
||||
@@ -76,6 +78,12 @@ const categoriesConfig: {
|
||||
value:
|
||||
'Tools for working with videos – extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.'
|
||||
},
|
||||
{
|
||||
type: 'pdf',
|
||||
icon: 'tabler:pdf',
|
||||
value:
|
||||
'Tools for working with PDF files - extract text from PDFs, convert PDFs to other formats, manipulate PDFs, and much more.'
|
||||
},
|
||||
{
|
||||
type: 'time',
|
||||
icon: 'fluent-mdl2:date-time',
|
||||
|
Reference in New Issue
Block a user