Merge branch 'iib0011:main' into feature/json2xml

This commit is contained in:
Luís Jesus
2025-03-26 11:23:53 +00:00
committed by GitHub
32 changed files with 1492 additions and 128 deletions

View File

@@ -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 && (

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' | '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>
);
}

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,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>
);
}

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,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

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

@@ -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
];

View 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.'
}}
/>
);
}

View 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'))
});

View File

@@ -0,0 +1,4 @@
import { meta as splitPdfMeta } from './split-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
export const pdfTools: DefinedTool[] = [splitPdfMeta];

View 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`
}}
/>
);
}

View 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'
});

View 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]);
});
});

View 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' });
}

View File

@@ -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('');
});
});

View 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}
/>
);
}

View 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'))
});

View 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');
}

View File

@@ -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];

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);

View File

@@ -22,7 +22,8 @@ export type ToolCategory =
| 'list'
| 'json'
| 'csv'
| 'time';
| 'time'
| 'pdf';
export interface DefinedTool {
type: ToolCategory;

View File

@@ -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',