mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-20 22:49:33 +02:00
Merge branch 'main' into chesterkxng
This commit is contained in:
@@ -40,7 +40,7 @@ const FormikListenerComponent = <T,>({
|
||||
|
||||
interface ToolContentProps<T, I> extends ToolComponentProps {
|
||||
inputComponent?: ReactNode;
|
||||
resultComponent: ReactNode;
|
||||
resultComponent?: ReactNode;
|
||||
renderCustomInput?: (
|
||||
values: T,
|
||||
setFieldValue: (fieldName: string, value: any) => void
|
||||
@@ -57,6 +57,7 @@ interface ToolContentProps<T, I> extends ToolComponentProps {
|
||||
setInput?: React.Dispatch<React.SetStateAction<I>>;
|
||||
validationSchema?: any;
|
||||
onValuesChange?: (values: T) => void;
|
||||
verticalGroups?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolContent<T extends FormikValues, I>({
|
||||
@@ -72,7 +73,8 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
setInput,
|
||||
validationSchema,
|
||||
renderCustomInput,
|
||||
onValuesChange
|
||||
onValuesChange,
|
||||
verticalGroups
|
||||
}: ToolContentProps<T, I>) {
|
||||
return (
|
||||
<Box>
|
||||
@@ -97,7 +99,7 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
input={input}
|
||||
onValuesChange={onValuesChange}
|
||||
/>
|
||||
<ToolOptions getGroups={getGroups} />
|
||||
<ToolOptions getGroups={getGroups} vertical={verticalGroups} />
|
||||
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
<ToolInfo
|
||||
|
@@ -6,18 +6,20 @@ export default function ToolInputAndResult({
|
||||
result
|
||||
}: {
|
||||
input?: ReactNode;
|
||||
result: ReactNode;
|
||||
result?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Grid id="tool" container spacing={2}>
|
||||
{input && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{input}
|
||||
if (input || result) {
|
||||
return (
|
||||
<Grid id="tool" container spacing={2}>
|
||||
{input && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{input}
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={input ? 6 : 12}>
|
||||
{result}
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={input ? 6 : 12}>
|
||||
{result}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useContext, useEffect } from 'react';
|
||||
import React, { ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import InputHeader from '../InputHeader';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { globalInputHeight } from '../../config/uiConfig';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
import greyPattern from '@assets/grey-pattern.png';
|
||||
import { isArray } from 'lodash';
|
||||
|
||||
interface BaseFileInputComponentProps extends BaseFileInputProps {
|
||||
children: (props: { preview: string | undefined }) => ReactNode;
|
||||
@@ -25,20 +26,22 @@ export default function BaseFileInput({
|
||||
children,
|
||||
type
|
||||
}: BaseFileInputComponentProps) {
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
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);
|
||||
}
|
||||
try {
|
||||
const objectUrl = createObjectURL(value);
|
||||
setPreview(objectUrl);
|
||||
return () => revokeObjectURL(objectUrl);
|
||||
} catch (error) {
|
||||
console.error('Error previewing file:', error);
|
||||
}
|
||||
} else setPreview(null);
|
||||
}, [value]);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -49,10 +52,11 @@ export default function BaseFileInput({
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (value) {
|
||||
const blob = new Blob([value], { type: value.type });
|
||||
const clipboardItem = new ClipboardItem({ [value.type]: blob });
|
||||
if (isArray(value)) {
|
||||
const blob = new Blob([value[0]], { type: value[0].type });
|
||||
const clipboardItem = new ClipboardItem({ [value[0].type]: blob });
|
||||
|
||||
navigator.clipboard
|
||||
.write([clipboardItem])
|
||||
@@ -63,6 +67,52 @@ export default function BaseFileInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
const file = event.dataTransfer.files[0];
|
||||
|
||||
// Check if file type is acceptable
|
||||
const isAcceptable = accept.some((acceptType) => {
|
||||
// Handle wildcards like "image/*"
|
||||
if (acceptType.endsWith('/*')) {
|
||||
const category = acceptType.split('/')[0];
|
||||
return file.type.startsWith(category);
|
||||
}
|
||||
return acceptType === file.type;
|
||||
});
|
||||
|
||||
if (isAcceptable) {
|
||||
onChange(file);
|
||||
} else {
|
||||
showSnackBar(
|
||||
`Invalid file type. Please use ${accept.join(', ')}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const clipboardItems = event.clipboardData?.items ?? [];
|
||||
@@ -95,8 +145,15 @@ export default function BaseFileInput({
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'background.paper',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
borderColor: isDragging ? theme.palette.primary.main : undefined,
|
||||
borderWidth: isDragging ? 2 : 1,
|
||||
borderStyle: isDragging ? 'dashed' : 'solid'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{preview ? (
|
||||
<Box
|
||||
@@ -127,17 +184,27 @@ export default function BaseFileInput({
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
color={
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey['300']
|
||||
: 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>
|
||||
{isDragging ? (
|
||||
<Typography
|
||||
color={theme.palette.primary.main}
|
||||
variant="h6"
|
||||
align="center"
|
||||
>
|
||||
Drop your {type} here
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
color={
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey['300']
|
||||
: theme.palette.grey['600']
|
||||
}
|
||||
>
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, or drag and drop a file from
|
||||
desktop
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -148,6 +215,7 @@ export default function BaseFileInput({
|
||||
type="file"
|
||||
accept={accept.join(',')}
|
||||
onChange={handleFileChange}
|
||||
multiple={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
@@ -2,23 +2,32 @@ import { Stack } from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import PublishIcon from '@mui/icons-material/Publish';
|
||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import React from 'react';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
|
||||
export default function InputFooter({
|
||||
handleImport,
|
||||
handleCopy
|
||||
handleCopy,
|
||||
handleClear
|
||||
}: {
|
||||
handleImport: () => void;
|
||||
handleCopy: () => void;
|
||||
handleCopy?: () => void;
|
||||
handleClear?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack mt={1} direction={'row'} spacing={2}>
|
||||
<Button onClick={handleImport} startIcon={<PublishIcon />}>
|
||||
Import from file
|
||||
</Button>
|
||||
<Button onClick={handleCopy} startIcon={<ContentPasteIcon />}>
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
{handleCopy && (
|
||||
<Button onClick={handleCopy} startIcon={<ContentPasteIcon />}>
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
)}
|
||||
{handleClear && (
|
||||
<Button onClick={handleClear} startIcon={<ClearIcon />}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
178
src/components/input/NumericInputWithUnit.tsx
Normal file
178
src/components/input/NumericInputWithUnit.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grid, Select, MenuItem } from '@mui/material';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import Qty from 'js-quantities';
|
||||
//
|
||||
|
||||
const siPrefixes: { [key: string]: number } = {
|
||||
'Default prefix': 1,
|
||||
k: 1000,
|
||||
M: 1000000,
|
||||
G: 1000000000,
|
||||
T: 1000000000000,
|
||||
m: 0.001,
|
||||
u: 0.000001,
|
||||
n: 0.000000001,
|
||||
p: 0.000000000001
|
||||
};
|
||||
|
||||
export default function NumericInputWithUnit(props: {
|
||||
value: { value: number; unit: string };
|
||||
disabled?: boolean;
|
||||
disableChangingUnit?: boolean;
|
||||
onOwnChange?: (value: { value: number; unit: string }) => void;
|
||||
defaultPrefix?: string;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(props.value.value);
|
||||
const [prefix, setPrefix] = useState(props.defaultPrefix || 'Default prefix');
|
||||
|
||||
// internal display unit
|
||||
const [unit, setUnit] = useState('');
|
||||
|
||||
// Whether user has overridden the unit
|
||||
const [userSelectedUnit, setUserSelectedUnit] = useState(false);
|
||||
const [unitKind, setUnitKind] = useState('');
|
||||
const [unitOptions, setUnitOptions] = useState<string[]>([]);
|
||||
|
||||
const [disabled, setDisabled] = useState(props.disabled);
|
||||
const [disableChangingUnit, setDisableChangingUnit] = useState(
|
||||
props.disableChangingUnit
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDisabled(props.disabled);
|
||||
setDisableChangingUnit(props.disableChangingUnit);
|
||||
}, [props.disabled, props.disableChangingUnit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (unitKind != Qty(props.value.unit).kind()) {
|
||||
// Update the options for what units similar to this one are available
|
||||
const kind = Qty(props.value.unit).kind();
|
||||
let units: string[] = [];
|
||||
if (kind) {
|
||||
units = Qty.getUnits(kind);
|
||||
}
|
||||
|
||||
if (!units.includes(props.value.unit)) {
|
||||
units.push(props.value.unit);
|
||||
}
|
||||
|
||||
// Workaround because the lib doesn't list them
|
||||
if (kind == 'area') {
|
||||
units.push('km^2');
|
||||
units.push('mile^2');
|
||||
units.push('inch^2');
|
||||
units.push('m^2');
|
||||
units.push('cm^2');
|
||||
}
|
||||
setUnitOptions(units);
|
||||
setInputValue(props.value.value);
|
||||
setUnit(props.value.unit);
|
||||
setUnitKind(kind);
|
||||
setUserSelectedUnit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userSelectedUnit) {
|
||||
if (!isNaN(props.value.value)) {
|
||||
const converted = Qty(props.value.value, props.value.unit).to(
|
||||
unit
|
||||
).scalar;
|
||||
setInputValue(converted);
|
||||
} else {
|
||||
setInputValue(props.value.value);
|
||||
}
|
||||
} else {
|
||||
setInputValue(props.value.value);
|
||||
setUnit(props.value.unit);
|
||||
}
|
||||
}, [props.value.value, props.value.unit, unit]);
|
||||
|
||||
const handleUserValueChange = (newValue: number) => {
|
||||
setInputValue(newValue);
|
||||
|
||||
if (props.onOwnChange) {
|
||||
try {
|
||||
const converted = Qty(newValue * siPrefixes[prefix], unit).to(
|
||||
props.value.unit
|
||||
).scalar;
|
||||
|
||||
props.onOwnChange({ unit: props.value.unit, value: converted });
|
||||
} catch (error) {
|
||||
console.error('Conversion error', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrefixChange = (newPrefix: string) => {
|
||||
setPrefix(newPrefix);
|
||||
};
|
||||
|
||||
const handleUserUnitChange = (newUnit: string) => {
|
||||
if (!newUnit) return;
|
||||
const oldInputValue = inputValue;
|
||||
const oldUnit = unit;
|
||||
setUnit(newUnit);
|
||||
setPrefix('Default prefix');
|
||||
|
||||
const convertedValue = Qty(oldInputValue * siPrefixes[prefix], oldUnit).to(
|
||||
newUnit
|
||||
).scalar;
|
||||
setInputValue(convertedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextFieldWithDesc
|
||||
disabled={disabled}
|
||||
type="number"
|
||||
fullWidth
|
||||
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
|
||||
value={(inputValue / siPrefixes[prefix])
|
||||
.toFixed(9)
|
||||
.replace(/(\d*\.\d+?)0+$/, '$1')}
|
||||
onOwnChange={(value) => handleUserValueChange(parseFloat(value))}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Select
|
||||
fullWidth
|
||||
disabled={disableChangingUnit}
|
||||
value={prefix}
|
||||
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
|
||||
onChange={(evt) => {
|
||||
handlePrefixChange(evt.target.value || '');
|
||||
}}
|
||||
>
|
||||
{Object.keys(siPrefixes).map((key) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{key}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={5}>
|
||||
<Select
|
||||
fullWidth
|
||||
disabled={disableChangingUnit}
|
||||
placeholder={'Unit'}
|
||||
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
|
||||
value={unit}
|
||||
onChange={(event) => {
|
||||
setUserSelectedUnit(true);
|
||||
handleUserUnitChange(event.target.value || '');
|
||||
}}
|
||||
>
|
||||
{unitOptions.map((key) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{key}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
176
src/components/input/ToolMultiplePdfInput.tsx
Normal file
176
src/components/input/ToolMultiplePdfInput.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import InputHeader from '../InputHeader';
|
||||
import InputFooter from './InputFooter';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
import { isArray } from 'lodash';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
|
||||
interface MultiPdfInputComponentProps {
|
||||
accept: string[];
|
||||
title?: string;
|
||||
type: 'pdf';
|
||||
value: MultiPdfInput[];
|
||||
onChange: (file: MultiPdfInput[]) => void;
|
||||
}
|
||||
|
||||
export interface MultiPdfInput {
|
||||
file: File;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export default function ToolMultiFileInput({
|
||||
value,
|
||||
onChange,
|
||||
accept,
|
||||
title,
|
||||
type
|
||||
}: MultiPdfInputComponentProps) {
|
||||
const theme = useTheme();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files)
|
||||
onChange([
|
||||
...value,
|
||||
...Array.from(files).map((file) => ({ file, order: value.length }))
|
||||
]);
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
function handleClear() {
|
||||
onChange([]);
|
||||
}
|
||||
|
||||
function fileNameTruncate(fileName: string) {
|
||||
const maxLength = 10;
|
||||
if (fileName.length > maxLength) {
|
||||
return fileName.slice(0, maxLength) + '...';
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
const sortList = () => {
|
||||
const list = [...value];
|
||||
list.sort((a, b) => a.order - b.order);
|
||||
onChange(list);
|
||||
};
|
||||
|
||||
const reorderList = (sourceIndex: number, destinationIndex: number) => {
|
||||
console.log(sourceIndex, destinationIndex);
|
||||
if (destinationIndex === sourceIndex) {
|
||||
return;
|
||||
}
|
||||
const list = [...value];
|
||||
|
||||
if (destinationIndex === 0) {
|
||||
list[sourceIndex].order = list[0].order - 1;
|
||||
sortList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (destinationIndex === list.length - 1) {
|
||||
list[sourceIndex].order = list[list.length - 1].order + 1;
|
||||
sortList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (destinationIndex < sourceIndex) {
|
||||
list[sourceIndex].order =
|
||||
(list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
|
||||
sortList();
|
||||
return;
|
||||
}
|
||||
|
||||
list[sourceIndex].order =
|
||||
(list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
|
||||
sortList();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader
|
||||
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
|
||||
border: value?.length ? 0 : 1,
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'background.paper',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{value?.length ? (
|
||||
value.map((file, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
margin: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '200px',
|
||||
border: 1,
|
||||
borderRadius: 1,
|
||||
padding: 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PictureAsPdfIcon />
|
||||
<Typography sx={{ marginLeft: 1 }}>
|
||||
{fileNameTruncate(file.file.name)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
const updatedFiles = value.filter((_, i) => i !== index);
|
||||
onChange(updatedFiles);
|
||||
}}
|
||||
>
|
||||
✖
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No files selected
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
accept={accept.join(',')}
|
||||
onChange={handleFileChange}
|
||||
multiple={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -5,11 +5,12 @@ import 'rc-slider/assets/index.css';
|
||||
import BaseFileInput from './BaseFileInput';
|
||||
import { BaseFileInputProps, formatTime } from './file-input-utils';
|
||||
|
||||
interface VideoFileInputProps extends BaseFileInputProps {
|
||||
interface VideoFileInputProps extends Omit<BaseFileInputProps, 'accept'> {
|
||||
showTrimControls?: boolean;
|
||||
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
accept?: string[];
|
||||
}
|
||||
|
||||
export default function ToolVideoInput({
|
||||
@@ -17,6 +18,7 @@ export default function ToolVideoInput({
|
||||
onTrimChange,
|
||||
trimStart = 0,
|
||||
trimEnd = 100,
|
||||
accept = ['video/*', '.mkv'],
|
||||
...props
|
||||
}: VideoFileInputProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -38,7 +40,7 @@ export default function ToolVideoInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseFileInput {...props} type={'video'}>
|
||||
<BaseFileInput {...props} type={'video'} accept={accept}>
|
||||
{({ preview }) => (
|
||||
<Box
|
||||
sx={{
|
||||
|
@@ -13,10 +13,12 @@ export type GetGroupsType<T> = (
|
||||
|
||||
export default function ToolOptions<T extends FormikValues>({
|
||||
children,
|
||||
getGroups
|
||||
getGroups,
|
||||
vertical
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const formikContext = useFormikContext<T>();
|
||||
@@ -49,6 +51,7 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<ToolOptionGroups
|
||||
groups={getGroups({ ...formikContext, updateField }) ?? []}
|
||||
vertical={vertical}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
|
@@ -173,7 +173,9 @@ export default function ToolFileResult({
|
||||
disabled={!value}
|
||||
handleCopy={handleCopy}
|
||||
handleDownload={handleDownload}
|
||||
hideCopy={fileType === 'video' || fileType === 'audio'}
|
||||
hideCopy={
|
||||
fileType === 'video' || fileType === 'audio' || fileType === 'pdf'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
18
src/datatables/data/material_electrical_properties.ts
Normal file
18
src/datatables/data/material_electrical_properties.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
title: 'Material Electrical Properties',
|
||||
columns: {
|
||||
resistivity_20c: {
|
||||
title: 'Resistivity at 20°C',
|
||||
type: 'number',
|
||||
unit: 'Ω/m'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
Copper: {
|
||||
resistivity_20c: 1.68e-8
|
||||
},
|
||||
Aluminum: {
|
||||
resistivity_20c: 2.82e-8
|
||||
}
|
||||
}
|
||||
};
|
73
src/datatables/data/wire_gauge.ts
Normal file
73
src/datatables/data/wire_gauge.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { DataTable } from '../types';
|
||||
|
||||
const data: DataTable = {
|
||||
title: 'American Wire Gauge',
|
||||
columns: {
|
||||
diameter: {
|
||||
title: 'Diameter',
|
||||
type: 'number',
|
||||
unit: 'mm'
|
||||
},
|
||||
area: {
|
||||
title: 'Area',
|
||||
type: 'number',
|
||||
unit: 'mm2'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
'0000 AWG': { diameter: 11.684 },
|
||||
'000 AWG': { diameter: 10.405 },
|
||||
'00 AWG': { diameter: 9.266 },
|
||||
'0 AWG': { diameter: 8.251 },
|
||||
'(4/0) AWG': { diameter: 11.684 },
|
||||
'(3/0) AWG': { diameter: 10.405 },
|
||||
'(2/0) AWG': { diameter: 9.266 },
|
||||
'(1/0) AWG': { diameter: 8.251 },
|
||||
'1 AWG': { diameter: 7.348 },
|
||||
'2 AWG': { diameter: 6.544 },
|
||||
'3 AWG': { diameter: 5.827 },
|
||||
'4 AWG': { diameter: 5.189 },
|
||||
'5 AWG': { diameter: 4.621 },
|
||||
'6 AWG': { diameter: 4.115 },
|
||||
'7 AWG': { diameter: 3.665 },
|
||||
'8 AWG': { diameter: 3.264 },
|
||||
'9 AWG': { diameter: 2.906 },
|
||||
'10 AWG': { diameter: 2.588 },
|
||||
'11 AWG': { diameter: 2.305 },
|
||||
'12 AWG': { diameter: 2.053 },
|
||||
'13 AWG': { diameter: 1.828 },
|
||||
'14 AWG': { diameter: 1.628 },
|
||||
'15 AWG': { diameter: 1.45 },
|
||||
'16 AWG': { diameter: 1.291 },
|
||||
'17 AWG': { diameter: 1.15 },
|
||||
'18 AWG': { diameter: 1.024 },
|
||||
'19 AWG': { diameter: 0.912 },
|
||||
'20 AWG': { diameter: 0.812 },
|
||||
'21 AWG': { diameter: 0.723 },
|
||||
'22 AWG': { diameter: 0.644 },
|
||||
'23 AWG': { diameter: 0.573 },
|
||||
'24 AWG': { diameter: 0.511 },
|
||||
'25 AWG': { diameter: 0.455 },
|
||||
'26 AWG': { diameter: 0.405 },
|
||||
'27 AWG': { diameter: 0.361 },
|
||||
'28 AWG': { diameter: 0.321 },
|
||||
'29 AWG': { diameter: 0.286 },
|
||||
'30 AWG': { diameter: 0.255 },
|
||||
'31 AWG': { diameter: 0.227 },
|
||||
'32 AWG': { diameter: 0.202 },
|
||||
'33 AWG': { diameter: 0.18 },
|
||||
'34 AWG': { diameter: 0.16 },
|
||||
'35 AWG': { diameter: 0.143 },
|
||||
'36 AWG': { diameter: 0.127 },
|
||||
'37 AWG': { diameter: 0.113 },
|
||||
'38 AWG': { diameter: 0.101 },
|
||||
'39 AWG': { diameter: 0.0897 },
|
||||
'40 AWG': { diameter: 0.0799 }
|
||||
}
|
||||
};
|
||||
|
||||
for (const key in data.data) {
|
||||
data.data[key].area = Math.PI * (data.data[key].diameter / 2) ** 2;
|
||||
}
|
||||
|
||||
export default data;
|
8
src/datatables/index.ts
Normal file
8
src/datatables/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { DataTable } from './types.ts';
|
||||
|
||||
/* Used in case later we want any kind of computed extra data */
|
||||
export function dataTableLookup(table: DataTable, key: string): any {
|
||||
return table.data[key];
|
||||
}
|
||||
|
||||
export { DataTable };
|
17
src/datatables/types.ts
Normal file
17
src/datatables/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
Represents a set of rows indexed by a key.
|
||||
Used for calculator presets
|
||||
|
||||
*/
|
||||
export interface DataTable {
|
||||
title: string;
|
||||
/* A JSON schema properties */
|
||||
columns: {
|
||||
[key: string]: { title: string; type: string; unit: string };
|
||||
};
|
||||
data: {
|
||||
[key: string]: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
}
|
12
src/pages/tools/number/generic-calc/data/index.ts
Normal file
12
src/pages/tools/number/generic-calc/data/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import ohmslaw from './ohmsLaw';
|
||||
import voltageDropInWire from './voltageDropInWire';
|
||||
import sphereArea from './sphereArea';
|
||||
import sphereVolume from './sphereVolume';
|
||||
import slackline from './slackline';
|
||||
export default [
|
||||
ohmslaw,
|
||||
voltageDropInWire,
|
||||
sphereArea,
|
||||
sphereVolume,
|
||||
slackline
|
||||
];
|
46
src/pages/tools/number/generic-calc/data/ohmsLaw.ts
Normal file
46
src/pages/tools/number/generic-calc/data/ohmsLaw.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const ohmsLawCalc: GenericCalcType = {
|
||||
icon: 'mdi:ohm',
|
||||
keywords: [
|
||||
'ohm',
|
||||
'voltage',
|
||||
'current',
|
||||
'resistance',
|
||||
'electrical',
|
||||
'circuit',
|
||||
'electronics',
|
||||
'power',
|
||||
'V=IR'
|
||||
],
|
||||
shortDescription:
|
||||
"Calculate voltage, current, or resistance in electrical circuits using Ohm's Law",
|
||||
name: "Ohm's Law",
|
||||
path: 'ohms-law',
|
||||
description: 'Calculates voltage, current and resistance',
|
||||
longDescription:
|
||||
"This calculator applies Ohm's Law (V = I × R) to determine any of the three electrical parameters when the other two are known. Ohm's Law is a fundamental principle in electrical engineering that describes the relationship between voltage (V), current (I), and resistance (R). This tool is essential for electronics hobbyists, electrical engineers, and students working with circuits to quickly solve for unknown values in their electrical designs.",
|
||||
formula: 'V = I * R',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'V',
|
||||
title: 'Voltage',
|
||||
unit: 'volt',
|
||||
default: 5
|
||||
},
|
||||
{
|
||||
name: 'I',
|
||||
title: 'Current',
|
||||
unit: 'ampere',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'R',
|
||||
title: 'Resistance',
|
||||
unit: 'ohm'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default ohmsLawCalc;
|
49
src/pages/tools/number/generic-calc/data/slackline.ts
Normal file
49
src/pages/tools/number/generic-calc/data/slackline.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const slackline: GenericCalcType = {
|
||||
icon: 'mdi:bridge',
|
||||
keywords: [
|
||||
'mechanical',
|
||||
'rope',
|
||||
'webbing',
|
||||
'cord',
|
||||
'string',
|
||||
'tension',
|
||||
'clothesline'
|
||||
],
|
||||
shortDescription:
|
||||
'Calculate the approximate tension of a slackline or clothesline. Do not rely on this for safety.',
|
||||
name: 'Slackline Tension',
|
||||
path: 'slackline-tension',
|
||||
description: 'Calculates tension in a slackline',
|
||||
longDescription: 'This calculator assumes a load in the center of the rope',
|
||||
formula: 'T = (W * sqrt((S**2) + ((L/2)**2)) )/ (2S)',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'L',
|
||||
title: 'Length',
|
||||
unit: 'meter',
|
||||
default: 2
|
||||
},
|
||||
{
|
||||
name: 'W',
|
||||
title: 'Weight',
|
||||
unit: 'pound',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'S',
|
||||
title: 'Sag/Deflection',
|
||||
unit: 'meter',
|
||||
default: 0.05
|
||||
},
|
||||
{
|
||||
name: 'T',
|
||||
title: 'Tension',
|
||||
unit: 'pound-force'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default slackline;
|
41
src/pages/tools/number/generic-calc/data/sphereArea.ts
Normal file
41
src/pages/tools/number/generic-calc/data/sphereArea.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const areaSphere: GenericCalcType = {
|
||||
icon: 'ph:sphere-duotone',
|
||||
keywords: [
|
||||
'sphere',
|
||||
'area',
|
||||
'surface area',
|
||||
'geometry',
|
||||
'mathematics',
|
||||
'radius',
|
||||
'calculation',
|
||||
'3D',
|
||||
'shape'
|
||||
],
|
||||
shortDescription:
|
||||
'Calculate the surface area of a sphere based on its radius',
|
||||
name: 'Area of a Sphere',
|
||||
path: 'area-sphere',
|
||||
description: 'Area of a Sphere',
|
||||
longDescription:
|
||||
'This calculator determines the surface area of a sphere using the formula A = 4πr². You can either input the radius to find the surface area or enter the surface area to calculate the required radius. This tool is useful for students studying geometry, engineers working with spherical objects, and anyone needing to perform calculations involving spherical surfaces.',
|
||||
formula: 'A = 4 * pi * r**2',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'A',
|
||||
title: 'Area',
|
||||
unit: 'mm2'
|
||||
},
|
||||
{
|
||||
name: 'r',
|
||||
title: 'Radius',
|
||||
formula: 'r = sqrt(A/pi) / 2',
|
||||
unit: 'mm',
|
||||
default: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default areaSphere;
|
47
src/pages/tools/number/generic-calc/data/sphereVolume.ts
Normal file
47
src/pages/tools/number/generic-calc/data/sphereVolume.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const volumeSphere: GenericCalcType = {
|
||||
icon: 'gravity-ui:sphere',
|
||||
keywords: [
|
||||
'sphere',
|
||||
'volume',
|
||||
'geometry',
|
||||
'mathematics',
|
||||
'radius',
|
||||
'diameter',
|
||||
'calculation',
|
||||
'3D',
|
||||
'shape',
|
||||
'capacity'
|
||||
],
|
||||
shortDescription: 'Calculate the volume of a sphere using radius or diameter',
|
||||
name: 'Volume of a Sphere',
|
||||
path: 'volume-sphere',
|
||||
description: 'Volume of a Sphere',
|
||||
longDescription:
|
||||
'This calculator computes the volume of a sphere using the formula V = (4/3)πr³. You can input either the radius or diameter to find the volume, or enter the volume to determine the required radius. The tool is valuable for students, engineers, and professionals working with spherical objects in fields such as physics, engineering, and manufacturing.',
|
||||
formula: 'v = (4/3) * pi * r**3',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'v',
|
||||
title: 'Volume',
|
||||
unit: 'mm3'
|
||||
},
|
||||
{
|
||||
name: 'r',
|
||||
title: 'Radius',
|
||||
unit: 'mm',
|
||||
default: 1,
|
||||
alternates: [
|
||||
{
|
||||
title: 'Diameter',
|
||||
formula: 'x = 2 * v',
|
||||
unit: 'mm'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default volumeSphere;
|
49
src/pages/tools/number/generic-calc/data/types.ts
Normal file
49
src/pages/tools/number/generic-calc/data/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DataTable } from '../../../../../datatables';
|
||||
import { ToolMeta } from '@tools/defineTool';
|
||||
|
||||
export interface AlternativeVarInfo {
|
||||
title: string;
|
||||
unit: string;
|
||||
defaultPrefix?: string;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
export interface GenericCalcType extends Omit<ToolMeta, 'component'> {
|
||||
formula: string;
|
||||
extraOutputs?: {
|
||||
title: string;
|
||||
formula: string;
|
||||
unit: string;
|
||||
// Si prefix default
|
||||
defaultPrefix?: string;
|
||||
}[];
|
||||
presets?: {
|
||||
title: string;
|
||||
source: DataTable;
|
||||
default: string;
|
||||
bind: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}[];
|
||||
variables: {
|
||||
name: string;
|
||||
title: string;
|
||||
unit: string;
|
||||
defaultPrefix?: string;
|
||||
// If absence, assume it's the default target var
|
||||
default?: number;
|
||||
|
||||
// If present and false, don't allow user to select this as output
|
||||
solvable?: boolean;
|
||||
|
||||
// Alternate rearrangement of the formula, to be used when calculating this.
|
||||
// If missing, the main formula is used with auto derivation.
|
||||
formula?: string;
|
||||
|
||||
// Alternates are alternate ways of entering the exact same thing,
|
||||
// like the diameter or radius. The formula for an alternate
|
||||
// can use only one variable, always called v, which is the main
|
||||
// variable it's an alternate of
|
||||
alternates?: AlternativeVarInfo[];
|
||||
}[];
|
||||
}
|
@@ -0,0 +1,95 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
import material_electrical_properties from '../../../../../datatables/data/material_electrical_properties';
|
||||
import wire_gauge from '../../../../../datatables/data/wire_gauge';
|
||||
|
||||
const voltageDropInWire: GenericCalcType = {
|
||||
icon: 'simple-icons:wire',
|
||||
keywords: [
|
||||
'voltage drop',
|
||||
'cable',
|
||||
'wire',
|
||||
'electrical',
|
||||
'resistance',
|
||||
'power loss',
|
||||
'conductor',
|
||||
'resistivity',
|
||||
'AWG',
|
||||
'gauge'
|
||||
],
|
||||
shortDescription:
|
||||
'Calculate voltage drop and power loss in electrical cables based on length, material, and current',
|
||||
name: 'Round trip voltage drop in cable',
|
||||
path: 'cable-voltage-drop',
|
||||
formula: 'x = (((p * L) / (A/10**6) ) *2) * I',
|
||||
description:
|
||||
'Calculates round trip voltage and power loss in a 2 conductor cable',
|
||||
longDescription:
|
||||
'This calculator helps determine the voltage drop and power loss in a two-conductor electrical cable. It takes into account the cable length, wire gauge (cross-sectional area), material resistivity, and current flow. The tool calculates the round-trip voltage drop, total resistance of the cable, and the power dissipated as heat. This is particularly useful for electrical engineers, electricians, and hobbyists when designing electrical systems to ensure voltage levels remain within acceptable limits at the load.',
|
||||
presets: [
|
||||
{
|
||||
title: 'Material',
|
||||
source: material_electrical_properties,
|
||||
default: 'Copper',
|
||||
bind: {
|
||||
p: 'resistivity_20c'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Wire Gauge',
|
||||
source: wire_gauge,
|
||||
default: '24 AWG',
|
||||
bind: {
|
||||
A: 'area'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
extraOutputs: [
|
||||
{
|
||||
title: 'Total Resistance',
|
||||
formula: '((p * L) / (A/10**6))*2',
|
||||
unit: 'Ω'
|
||||
},
|
||||
{
|
||||
title: 'Total Power Dissipated',
|
||||
formula: 'I**2 * (((p * L) / (A/10**6))*2)',
|
||||
unit: 'W'
|
||||
}
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
name: 'L',
|
||||
title: 'Length',
|
||||
unit: 'meter',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'A',
|
||||
title: 'Wire Area',
|
||||
unit: 'mm2',
|
||||
default: 1
|
||||
},
|
||||
|
||||
{
|
||||
name: 'I',
|
||||
title: 'Current',
|
||||
unit: 'A',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'p',
|
||||
title: 'Resistivity',
|
||||
unit: 'Ω/m3',
|
||||
default: 1,
|
||||
defaultPrefix: 'n'
|
||||
},
|
||||
{
|
||||
name: 'x',
|
||||
title: 'Voltage Drop',
|
||||
unit: 'V'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default voltageDropInWire;
|
583
src/pages/tools/number/generic-calc/index.tsx
Normal file
583
src/pages/tools/number/generic-calc/index.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
MenuItem,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import NumericInputWithUnit from '@components/input/NumericInputWithUnit';
|
||||
import { UpdateField } from '@components/options/ToolOptions';
|
||||
import { InitialValuesType } from './types';
|
||||
import type { AlternativeVarInfo, GenericCalcType } from './data/types';
|
||||
import { dataTableLookup } from 'datatables';
|
||||
|
||||
import nerdamer from 'nerdamer-prime';
|
||||
import 'nerdamer-prime/Algebra';
|
||||
import 'nerdamer-prime/Solve';
|
||||
import 'nerdamer-prime/Calculus';
|
||||
import Qty from 'js-quantities';
|
||||
import { CustomSnackBarContext } from 'contexts/CustomSnackBarContext';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
|
||||
function numericSolveEquationFor(
|
||||
equation: string,
|
||||
varName: string,
|
||||
variables: { [key: string]: number }
|
||||
) {
|
||||
let expr = nerdamer(equation);
|
||||
for (const key in variables) {
|
||||
expr = expr.sub(key, variables[key].toString());
|
||||
}
|
||||
|
||||
let result: nerdamer.Expression | nerdamer.Expression[] =
|
||||
expr.solveFor(varName);
|
||||
|
||||
// Sometimes the result is an array, check for it while keeping linter happy
|
||||
if ((result as unknown as nerdamer.Expression).toDecimal === undefined) {
|
||||
result = (result as unknown as nerdamer.Expression[])[0];
|
||||
}
|
||||
|
||||
return parseFloat(
|
||||
(result as unknown as nerdamer.Expression).evaluate().toDecimal()
|
||||
);
|
||||
}
|
||||
|
||||
export default async function makeTool(
|
||||
calcData: GenericCalcType
|
||||
): Promise<React.JSXElementConstructor<ToolComponentProps>> {
|
||||
const initialValues: InitialValuesType = {
|
||||
outputVariable: '',
|
||||
vars: {},
|
||||
presets: {}
|
||||
};
|
||||
|
||||
return function GenericCalc({ title }: ToolComponentProps) {
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const theme = useTheme();
|
||||
const lessThanSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
// For UX purposes we need to track what vars are
|
||||
const [valsBoundToPreset, setValsBoundToPreset] = useState<{
|
||||
[key: string]: string;
|
||||
}>({});
|
||||
|
||||
const [extraOutputs, setExtraOutputs] = useState<{
|
||||
[key: string]: number;
|
||||
}>({});
|
||||
|
||||
const updateVarField = (
|
||||
name: string,
|
||||
value: number,
|
||||
unit: string,
|
||||
values: InitialValuesType,
|
||||
updateFieldFunc: UpdateField<InitialValuesType>
|
||||
) => {
|
||||
// Make copy
|
||||
const newVars = { ...values.vars };
|
||||
newVars[name] = {
|
||||
value,
|
||||
unit: unit
|
||||
};
|
||||
updateFieldFunc('vars', newVars);
|
||||
};
|
||||
|
||||
const handleSelectedTargetChange = (
|
||||
varName: string,
|
||||
updateFieldFunc: UpdateField<InitialValuesType>
|
||||
) => {
|
||||
updateFieldFunc('outputVariable', varName);
|
||||
};
|
||||
|
||||
const handleSelectedPresetChange = (
|
||||
selection: string,
|
||||
preset: string,
|
||||
currentValues: InitialValuesType,
|
||||
updateFieldFunc: UpdateField<InitialValuesType>
|
||||
) => {
|
||||
const newPresets = { ...currentValues.presets };
|
||||
newPresets[selection] = preset;
|
||||
updateFieldFunc('presets', newPresets);
|
||||
|
||||
// Clear old selection using setState callback pattern
|
||||
setValsBoundToPreset((prevState) => {
|
||||
const newState = { ...prevState };
|
||||
// Remove all keys bound to this selection
|
||||
Object.keys(newState).forEach((key) => {
|
||||
if (newState[key] === selection) {
|
||||
delete newState[key];
|
||||
}
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
|
||||
const selectionData = calcData.presets?.find(
|
||||
(sel) => sel.title === selection
|
||||
);
|
||||
|
||||
if (preset && preset != '<custom>') {
|
||||
if (selectionData) {
|
||||
// Create an object with the new bindings
|
||||
const newBindings: { [key: string]: string } = {};
|
||||
|
||||
for (const key in selectionData.bind) {
|
||||
// Add to newBindings for later state update
|
||||
newBindings[key] = selection;
|
||||
|
||||
if (currentValues.outputVariable === key) {
|
||||
handleSelectedTargetChange('', updateFieldFunc);
|
||||
}
|
||||
|
||||
updateVarField(
|
||||
key,
|
||||
|
||||
dataTableLookup(selectionData.source, preset)[
|
||||
selectionData.bind[key]
|
||||
],
|
||||
|
||||
selectionData.source.columns[selectionData.bind[key]]?.unit || '',
|
||||
currentValues,
|
||||
updateFieldFunc
|
||||
);
|
||||
}
|
||||
|
||||
// Update state with new bindings
|
||||
setValsBoundToPreset((prevState) => ({
|
||||
...prevState,
|
||||
...newBindings
|
||||
}));
|
||||
} else {
|
||||
throw new Error(
|
||||
`Preset "${preset}" is not valid for selection "${selection}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
calcData.variables.forEach((variable) => {
|
||||
if (variable.solvable === undefined) {
|
||||
variable.solvable = true;
|
||||
}
|
||||
if (variable.default === undefined) {
|
||||
initialValues.vars[variable.name] = {
|
||||
value: NaN,
|
||||
unit: variable.unit
|
||||
};
|
||||
initialValues.outputVariable = variable.name;
|
||||
} else {
|
||||
initialValues.vars[variable.name] = {
|
||||
value: variable.default || 0,
|
||||
unit: variable.unit
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
calcData.presets?.forEach((selection) => {
|
||||
initialValues.presets[selection.title] = selection.default;
|
||||
if (selection.default == '<custom>') return;
|
||||
for (const key in selection.bind) {
|
||||
initialValues.vars[key] = {
|
||||
value: dataTableLookup(selection.source, selection.default)[
|
||||
selection.bind[key]
|
||||
],
|
||||
|
||||
unit: selection.source.columns[selection.bind[key]]?.unit || ''
|
||||
};
|
||||
// We'll set this in useEffect instead of directly modifying state
|
||||
}
|
||||
});
|
||||
|
||||
function getAlternate(
|
||||
alternateInfo: AlternativeVarInfo,
|
||||
mainInfo: GenericCalcType['variables'][number],
|
||||
mainValue: {
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
) {
|
||||
if (isNaN(mainValue.value)) return NaN;
|
||||
const canonicalValue = Qty(mainValue.value, mainValue.unit).to(
|
||||
mainInfo.unit
|
||||
).scalar;
|
||||
|
||||
return numericSolveEquationFor(alternateInfo.formula, 'x', {
|
||||
v: canonicalValue
|
||||
});
|
||||
}
|
||||
|
||||
function getMainFromAlternate(
|
||||
alternateInfo: AlternativeVarInfo,
|
||||
mainInfo: GenericCalcType['variables'][number],
|
||||
alternateValue: {
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
) {
|
||||
if (isNaN(alternateValue.value)) return NaN;
|
||||
const canonicalValue = Qty(alternateValue.value, alternateValue.unit).to(
|
||||
alternateInfo.unit
|
||||
).scalar;
|
||||
|
||||
return numericSolveEquationFor(alternateInfo.formula, 'v', {
|
||||
x: canonicalValue
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
inputComponent={null}
|
||||
initialValues={initialValues}
|
||||
toolInfo={{
|
||||
title: calcData.name,
|
||||
description: calcData.longDescription
|
||||
}}
|
||||
verticalGroups
|
||||
getGroups={({ values, updateField }) => [
|
||||
...(calcData.presets?.length
|
||||
? [
|
||||
{
|
||||
title: 'Presets',
|
||||
component: (
|
||||
<Grid container spacing={2} maxWidth={500}>
|
||||
{calcData.presets?.map((preset) => (
|
||||
<Grid item xs={12} key={preset.title}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
>
|
||||
<Typography>{preset.title}</Typography>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id="combo-box-demo"
|
||||
value={values.presets[preset.title]}
|
||||
options={[
|
||||
'<custom>',
|
||||
...Object.keys(preset.source.data).sort()
|
||||
]}
|
||||
sx={{ width: '80%' }}
|
||||
onChange={(event, newValue) => {
|
||||
handleSelectedPresetChange(
|
||||
preset.title,
|
||||
newValue || '',
|
||||
values,
|
||||
updateField
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Preset" />
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'Variables',
|
||||
component: (
|
||||
<Box>
|
||||
{lessThanSmall ? (
|
||||
<Stack
|
||||
direction={'column'}
|
||||
spacing={2}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
>
|
||||
<Typography>Solve for</Typography>
|
||||
<Select
|
||||
sx={{ width: '80%' }}
|
||||
fullWidth
|
||||
value={values.outputVariable}
|
||||
onChange={(event) =>
|
||||
handleSelectedTargetChange(
|
||||
event.target.value,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
>
|
||||
{calcData.variables.map((variable) => (
|
||||
<MenuItem
|
||||
disabled={
|
||||
valsBoundToPreset[variable.name] !== undefined ||
|
||||
variable.solvable === false
|
||||
}
|
||||
key={variable.name}
|
||||
value={variable.name}
|
||||
>
|
||||
{variable.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
) : (
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={10}></Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography fontWeight="bold" align="center">
|
||||
Solve For
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{calcData.variables.map((variable) => (
|
||||
<Box
|
||||
key={variable.name}
|
||||
sx={{
|
||||
my: 3,
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={lessThanSmall ? 12 : 10}>
|
||||
<Box>
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography sx={{ minWidth: '8%' }}>
|
||||
{variable.title}
|
||||
</Typography>
|
||||
<NumericInputWithUnit
|
||||
defaultPrefix={variable.defaultPrefix}
|
||||
value={values.vars[variable.name]}
|
||||
disabled={
|
||||
values.outputVariable === variable.name ||
|
||||
valsBoundToPreset[variable.name] !== undefined
|
||||
}
|
||||
disableChangingUnit={
|
||||
valsBoundToPreset[variable.name] !== undefined
|
||||
}
|
||||
onOwnChange={(val) =>
|
||||
updateVarField(
|
||||
variable.name,
|
||||
val.value,
|
||||
val.unit,
|
||||
values,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{variable.alternates?.map((alt) => (
|
||||
<Box key={alt.title}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography sx={{ minWidth: '8%' }}>
|
||||
{alt.title}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<NumericInputWithUnit
|
||||
key={alt.title}
|
||||
defaultPrefix={alt.defaultPrefix || ''}
|
||||
value={{
|
||||
value:
|
||||
getAlternate(
|
||||
alt,
|
||||
variable,
|
||||
values.vars[variable.name]
|
||||
) || NaN,
|
||||
unit: alt.unit || ''
|
||||
}}
|
||||
disabled={
|
||||
values.outputVariable ===
|
||||
variable.name ||
|
||||
valsBoundToPreset[variable.name] !==
|
||||
undefined
|
||||
}
|
||||
disableChangingUnit={
|
||||
valsBoundToPreset[variable.name] !==
|
||||
undefined
|
||||
}
|
||||
onOwnChange={(val) =>
|
||||
updateVarField(
|
||||
variable.name,
|
||||
getMainFromAlternate(
|
||||
alt,
|
||||
variable,
|
||||
val
|
||||
),
|
||||
variable.unit,
|
||||
values,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{!lessThanSmall && (
|
||||
<Grid
|
||||
item
|
||||
xs={2}
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<Radio
|
||||
value={variable.name}
|
||||
checked={values.outputVariable === variable.name}
|
||||
disabled={
|
||||
valsBoundToPreset[variable.name] !== undefined ||
|
||||
variable.solvable === false
|
||||
}
|
||||
onClick={() =>
|
||||
handleSelectedTargetChange(
|
||||
variable.name,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
...(calcData.extraOutputs
|
||||
? [
|
||||
{
|
||||
title: 'Extra outputs',
|
||||
component: (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{calcData.extraOutputs?.map((extraOutput) => (
|
||||
<Grid item xs={12} key={extraOutput.title}>
|
||||
<Stack spacing={1}>
|
||||
<Typography>{extraOutput.title}</Typography>
|
||||
<NumericInputWithUnit
|
||||
disabled={true}
|
||||
defaultPrefix={extraOutput.defaultPrefix}
|
||||
value={{
|
||||
value: extraOutputs[extraOutput.title],
|
||||
unit: extraOutput.unit
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
compute={(values) => {
|
||||
if (values.outputVariable === '') {
|
||||
showSnackBar('Please select a solve for variable', 'error');
|
||||
return;
|
||||
}
|
||||
let expr: nerdamer.Expression | null = null;
|
||||
|
||||
for (const variable of calcData.variables) {
|
||||
if (variable.name === values.outputVariable) {
|
||||
if (variable.formula !== undefined) {
|
||||
expr = nerdamer(variable.formula);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expr == null) {
|
||||
expr = nerdamer(calcData.formula);
|
||||
}
|
||||
if (expr == null) {
|
||||
throw new Error('No formula found');
|
||||
}
|
||||
|
||||
Object.keys(values.vars).forEach((key) => {
|
||||
if (key === values.outputVariable) return;
|
||||
if (expr === null) {
|
||||
throw new Error('Math fail');
|
||||
}
|
||||
expr = expr.sub(key, values.vars[key].value.toString());
|
||||
});
|
||||
|
||||
let result: nerdamer.Expression | nerdamer.Expression[] =
|
||||
expr.solveFor(values.outputVariable);
|
||||
|
||||
// Sometimes the result is an array
|
||||
if (
|
||||
(result as unknown as nerdamer.Expression).toDecimal === undefined
|
||||
) {
|
||||
if ((result as unknown as nerdamer.Expression[])?.length < 1) {
|
||||
values.vars[values.outputVariable].value = NaN;
|
||||
if (calcData.extraOutputs !== undefined) {
|
||||
// Update extraOutputs using setState
|
||||
setExtraOutputs((prevState) => {
|
||||
const newState = { ...prevState };
|
||||
for (let i = 0; i < calcData.extraOutputs!.length; i++) {
|
||||
const extraOutput = calcData.extraOutputs![i];
|
||||
newState[extraOutput.title] = NaN;
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
throw new Error('No solution found for this input');
|
||||
}
|
||||
result = (result as unknown as nerdamer.Expression[])[0];
|
||||
}
|
||||
|
||||
if (result) {
|
||||
if (values.vars[values.outputVariable] != undefined) {
|
||||
values.vars[values.outputVariable].value = parseFloat(
|
||||
(result as unknown as nerdamer.Expression)
|
||||
.evaluate()
|
||||
.toDecimal()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
values.vars[values.outputVariable].value = NaN;
|
||||
}
|
||||
|
||||
if (calcData.extraOutputs !== undefined) {
|
||||
for (let i = 0; i < calcData.extraOutputs.length; i++) {
|
||||
const extraOutput = calcData.extraOutputs[i];
|
||||
|
||||
let expr = nerdamer(extraOutput.formula);
|
||||
|
||||
Object.keys(values.vars).forEach((key) => {
|
||||
expr = expr.sub(key, values.vars[key].value.toString());
|
||||
});
|
||||
|
||||
// todo could this have multiple solutions too?
|
||||
const result: nerdamer.Expression = expr.evaluate();
|
||||
|
||||
if (result) {
|
||||
// Update extraOutputs state properly
|
||||
setExtraOutputs((prevState) => ({
|
||||
...prevState,
|
||||
[extraOutput.title]: parseFloat(result.toDecimal())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
28
src/pages/tools/number/generic-calc/meta.ts
Normal file
28
src/pages/tools/number/generic-calc/meta.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DefinedTool, defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
import type { GenericCalcType } from './data/types';
|
||||
import allGenericCalcs from './data/index';
|
||||
|
||||
async function importComponent(data: GenericCalcType) {
|
||||
const x = await import('./index');
|
||||
return { default: await x.default(data) };
|
||||
}
|
||||
|
||||
const tools: DefinedTool[] = [];
|
||||
|
||||
allGenericCalcs.forEach((x) => {
|
||||
async function importComponent2() {
|
||||
return await importComponent(x);
|
||||
}
|
||||
|
||||
tools.push(
|
||||
defineTool('number', {
|
||||
...x,
|
||||
path: 'generic-calc/' + x.path,
|
||||
keywords: ['calculator', 'math', ...x.keywords],
|
||||
component: lazy(importComponent2)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export { tools };
|
14
src/pages/tools/number/generic-calc/types.ts
Normal file
14
src/pages/tools/number/generic-calc/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type InitialValuesType = {
|
||||
vars: {
|
||||
[key: string]: {
|
||||
value: number;
|
||||
unit: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Track preset selections
|
||||
presets: {
|
||||
[key: string]: string;
|
||||
};
|
||||
outputVariable: string;
|
||||
};
|
@@ -1,5 +1,10 @@
|
||||
import { tool as numberSum } from './sum/meta';
|
||||
import { tool as numberGenerate } from './generate/meta';
|
||||
import { tool as numberArithmeticSequence } from './arithmetic-sequence/meta';
|
||||
|
||||
export const numberTools = [numberSum, numberGenerate, numberArithmeticSequence];
|
||||
import { tools as genericCalcTools } from './generic-calc/meta';
|
||||
export const numberTools = [
|
||||
numberSum,
|
||||
numberGenerate,
|
||||
numberArithmeticSequence,
|
||||
...genericCalcTools
|
||||
];
|
||||
|
@@ -1,12 +1,14 @@
|
||||
import { tool as pdfRotatePdf } from './rotate-pdf/meta';
|
||||
import { meta as splitPdfMeta } from './split-pdf/meta';
|
||||
import { meta as mergePdf } from './merge-pdf/meta';
|
||||
import { DefinedTool } from '@tools/defineTool';
|
||||
import { tool as compressPdfTool } from './compress-pdf/meta';
|
||||
import { tool as protectPdfTool } from './protect-pdf/meta';
|
||||
import { DefinedTool } from '@tools/defineTool';
|
||||
|
||||
export const pdfTools: DefinedTool[] = [
|
||||
splitPdfMeta,
|
||||
pdfRotatePdf,
|
||||
compressPdfTool,
|
||||
protectPdfTool
|
||||
protectPdfTool,
|
||||
mergePdf
|
||||
];
|
||||
|
66
src/pages/tools/pdf/merge-pdf/index.tsx
Normal file
66
src/pages/tools/pdf/merge-pdf/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { mergePdf } from './service';
|
||||
import ToolMultiPdfInput, {
|
||||
MultiPdfInput
|
||||
} from '@components/input/ToolMultiplePdfInput';
|
||||
|
||||
export default function MergePdf({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<MultiPdfInput[]>([]);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
const compute = async (values: File[], input: MultiPdfInput[]) => {
|
||||
if (input.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const mergeResult = await mergePdf(input.map((i) => i.file));
|
||||
setResult(mergeResult);
|
||||
} catch (error) {
|
||||
throw new Error('Error merging PDF:' + error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
initialValues={input.map((i) => i.file)}
|
||||
compute={compute}
|
||||
inputComponent={
|
||||
<ToolMultiPdfInput
|
||||
value={input}
|
||||
onChange={(pdfInputs) => {
|
||||
setInput(pdfInputs);
|
||||
}}
|
||||
accept={['application/pdf']}
|
||||
title={'Input PDF'}
|
||||
type="pdf"
|
||||
/>
|
||||
}
|
||||
getGroups={null}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Output merged PDF'}
|
||||
value={result}
|
||||
extension={'pdf'}
|
||||
loading={isProcessing}
|
||||
loadingText={'Extracting pages'}
|
||||
/>
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'How to Use the Merge PDF Tool?',
|
||||
description: `This tool allows you to merge multiple PDF files into a single document.
|
||||
To use the tool, simply upload the PDF files you want to merge. The tool will then combine all pages from the input files into a single PDF document.`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
12
src/pages/tools/pdf/merge-pdf/meta.ts
Normal file
12
src/pages/tools/pdf/merge-pdf/meta.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const meta = defineTool('pdf', {
|
||||
name: 'Merge PDF',
|
||||
shortDescription: 'Merge multiple PDF files into a single document',
|
||||
description: 'Combine multiple PDF files into a single document.',
|
||||
icon: 'material-symbols-light:merge',
|
||||
component: lazy(() => import('./index')),
|
||||
keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'],
|
||||
path: 'merge-pdf'
|
||||
});
|
43
src/pages/tools/pdf/merge-pdf/service.test.ts
Normal file
43
src/pages/tools/pdf/merge-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]);
|
||||
});
|
||||
});
|
95
src/pages/tools/pdf/merge-pdf/service.ts
Normal file
95
src/pages/tools/pdf/merge-pdf/service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple PDF files into a single document
|
||||
* @param pdfFiles Array of PDF files to merge
|
||||
* @returns Promise resolving to a new PDF file with all pages combined
|
||||
*/
|
||||
export async function mergePdf(pdfFiles: File[]): Promise<File> {
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
for (const pdfFile of pdfFiles) {
|
||||
const arrayBuffer = await pdfFile.arrayBuffer();
|
||||
const pdf = await PDFDocument.load(arrayBuffer);
|
||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||
copiedPages.forEach((page) => mergedPdf.addPage(page));
|
||||
}
|
||||
|
||||
const mergedPdfBytes = await mergedPdf.save();
|
||||
const mergedFileName = 'merged.pdf';
|
||||
return new File([mergedPdfBytes], mergedFileName, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
}
|
@@ -37,7 +37,6 @@ export async function protectPdf(
|
||||
password: options.password
|
||||
};
|
||||
const protectedFileUrl: string = await protectWithGhostScript(dataObject);
|
||||
console.log('protected', protectedFileUrl);
|
||||
return await loadPDFData(
|
||||
protectedFileUrl,
|
||||
pdfFile.name.replace('.pdf', '-protected.pdf')
|
||||
|
@@ -10,6 +10,7 @@ import { InitialValuesType, RotationAngle } from './types';
|
||||
import { parsePageRanges, rotatePdf } from './service';
|
||||
import SimpleRadio from '@components/options/SimpleRadio';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { isArray } from 'lodash';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
rotationAngle: 90,
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
|
169
src/pages/tools/video/change-speed/index.tsx
Normal file
169
src/pages/tools/video/change-speed/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { InitialValuesType } from './types';
|
||||
import ToolVideoInput from '@components/input/ToolVideoInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
newSpeed: 2
|
||||
};
|
||||
|
||||
export default function ChangeSpeed({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// FFmpeg only supports a tempo between 0.5 and 2.0, so we chain filters
|
||||
const computeAudioFilter = (speed: number): string => {
|
||||
if (speed <= 2 && speed >= 0.5) {
|
||||
return `atempo=${speed}`;
|
||||
}
|
||||
|
||||
// Break into supported chunks
|
||||
const filters: string[] = [];
|
||||
let remainingSpeed = speed;
|
||||
while (remainingSpeed > 2.0) {
|
||||
filters.push('atempo=2.0');
|
||||
remainingSpeed /= 2.0;
|
||||
}
|
||||
while (remainingSpeed < 0.5) {
|
||||
filters.push('atempo=0.5');
|
||||
remainingSpeed /= 0.5;
|
||||
}
|
||||
filters.push(`atempo=${remainingSpeed.toFixed(2)}`);
|
||||
|
||||
return filters.join(',');
|
||||
};
|
||||
|
||||
const compute = (optionsValues: InitialValuesType, input: File | null) => {
|
||||
if (!input) return;
|
||||
const { newSpeed } = optionsValues;
|
||||
let ffmpeg: FFmpeg | null = null;
|
||||
let ffmpegLoaded = false;
|
||||
|
||||
const processVideo = async (
|
||||
file: File,
|
||||
newSpeed: number
|
||||
): Promise<void> => {
|
||||
if (newSpeed === 0) return;
|
||||
setLoading(true);
|
||||
|
||||
if (!ffmpeg) {
|
||||
ffmpeg = new FFmpeg();
|
||||
}
|
||||
|
||||
if (!ffmpegLoaded) {
|
||||
await ffmpeg.load({
|
||||
wasmURL:
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||
});
|
||||
ffmpegLoaded = true;
|
||||
}
|
||||
|
||||
// Write file to FFmpeg FS
|
||||
const fileName = file.name;
|
||||
const outputName = 'output.mp4';
|
||||
|
||||
try {
|
||||
ffmpeg.writeFile(fileName, await fetchFile(file));
|
||||
|
||||
const videoFilter = `setpts=${1 / newSpeed}*PTS`;
|
||||
const audioFilter = computeAudioFilter(newSpeed);
|
||||
|
||||
// Run FFmpeg command
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
fileName,
|
||||
'-vf',
|
||||
videoFilter,
|
||||
'-filter:a',
|
||||
audioFilter,
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-c:a',
|
||||
'aac',
|
||||
outputName
|
||||
]);
|
||||
|
||||
const data = await ffmpeg.readFile(outputName);
|
||||
|
||||
// Create new file from processed data
|
||||
const blob = new Blob([data], { type: 'video/mp4' });
|
||||
const newFile = new File(
|
||||
[blob],
|
||||
file.name.replace('.mp4', `-${newSpeed}x.mp4`),
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
|
||||
// Clean up to free memory
|
||||
await ffmpeg.deleteFile(fileName);
|
||||
await ffmpeg.deleteFile(outputName);
|
||||
|
||||
setResult(newFile);
|
||||
} catch (err) {
|
||||
console.error(`Failed to process video: ${err}`);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Here we set the output video
|
||||
processVideo(input, newSpeed);
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'New Video Speed',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.newSpeed.toString()}
|
||||
onOwnChange={(val) => updateField('newSpeed', Number(val))}
|
||||
description="Default multiplier: 2 means 2x faster"
|
||||
type="number"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult title="Setting Speed" value={null} loading={true} />
|
||||
) : (
|
||||
<ToolFileResult title="Edited Video" value={result} extension="mp4" />
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/video/change-speed/meta.ts
Normal file
13
src/pages/tools/video/change-speed/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Change speed',
|
||||
path: 'change-speed',
|
||||
icon: 'material-symbols-light:speed-outline',
|
||||
description:
|
||||
'This online utility lets you change the speed of a video. You can speed it up or slow it down.',
|
||||
shortDescription: 'Quickly change video speed',
|
||||
keywords: ['change', 'speed'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
8
src/pages/tools/video/change-speed/service.ts
Normal file
8
src/pages/tools/video/change-speed/service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
export function main(
|
||||
input: File | null,
|
||||
options: InitialValuesType
|
||||
): File | null {
|
||||
return input;
|
||||
}
|
3
src/pages/tools/video/change-speed/types.ts
Normal file
3
src/pages/tools/video/change-speed/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
newSpeed: number;
|
||||
};
|
@@ -160,7 +160,6 @@ export default function CompressVideo({ title }: ToolComponentProps) {
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
}
|
||||
|
113
src/pages/tools/video/flip/index.tsx
Normal file
113
src/pages/tools/video/flip/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { useCallback, 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 { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { debounce } from 'lodash';
|
||||
import ToolVideoInput from '@components/input/ToolVideoInput';
|
||||
import { flipVideo } from './service';
|
||||
import { FlipOrientation, InitialValuesType } from './types';
|
||||
import SimpleRadio from '@components/options/SimpleRadio';
|
||||
|
||||
export const initialValues: InitialValuesType = {
|
||||
orientation: 'horizontal'
|
||||
};
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
orientation: Yup.string()
|
||||
.oneOf(
|
||||
['horizontal', 'vertical'],
|
||||
'Orientation must be horizontal or vertical'
|
||||
)
|
||||
.required('Orientation is required')
|
||||
});
|
||||
|
||||
const orientationOptions: { value: FlipOrientation; label: string }[] = [
|
||||
{ value: 'horizontal', label: 'Horizontal (Mirror)' },
|
||||
{ value: 'vertical', label: 'Vertical (Upside Down)' }
|
||||
];
|
||||
|
||||
export default function FlipVideo({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const compute = async (
|
||||
optionsValues: InitialValuesType,
|
||||
input: File | null
|
||||
) => {
|
||||
if (!input) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const flippedFile = await flipVideo(input, optionsValues.orientation);
|
||||
setResult(flippedFile);
|
||||
} catch (error) {
|
||||
console.error('Error flipping video:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedCompute = useCallback(debounce(compute, 1000), []);
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Orientation',
|
||||
component: (
|
||||
<Box>
|
||||
{orientationOptions.map((orientationOption) => (
|
||||
<SimpleRadio
|
||||
key={orientationOption.value}
|
||||
title={orientationOption.label}
|
||||
checked={values.orientation === orientationOption.value}
|
||||
onClick={() => {
|
||||
updateField('orientation', orientationOption.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult
|
||||
title={'Flipping Video'}
|
||||
value={null}
|
||||
loading={true}
|
||||
extension={''}
|
||||
/>
|
||||
) : (
|
||||
<ToolFileResult
|
||||
title={'Flipped Video'}
|
||||
value={result}
|
||||
extension={'mp4'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={debouncedCompute}
|
||||
setInput={setInput}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/video/flip/meta.ts
Normal file
15
src/pages/tools/video/flip/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Flip Video',
|
||||
path: 'flip',
|
||||
icon: 'mdi:flip-horizontal',
|
||||
description:
|
||||
'This online utility allows you to flip videos horizontally or vertically. You can preview the flipped video before processing. Supports common video formats like MP4, WebM, and OGG.',
|
||||
shortDescription: 'Flip videos horizontally or vertically',
|
||||
keywords: ['flip', 'video', 'mirror', 'edit', 'horizontal', 'vertical'],
|
||||
longDescription:
|
||||
'Easily flip your videos horizontally (mirror) or vertically (upside down) with this simple online tool.',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
43
src/pages/tools/video/flip/service.ts
Normal file
43
src/pages/tools/video/flip/service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
import { FlipOrientation } from './types';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
export async function flipVideo(
|
||||
input: File,
|
||||
orientation: FlipOrientation
|
||||
): Promise<File> {
|
||||
if (!ffmpeg.loaded) {
|
||||
await ffmpeg.load({
|
||||
wasmURL:
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||
});
|
||||
}
|
||||
|
||||
const inputName = 'input.mp4';
|
||||
const outputName = 'output.mp4';
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(input));
|
||||
|
||||
const flipMap: Record<FlipOrientation, string> = {
|
||||
horizontal: 'hflip',
|
||||
vertical: 'vflip'
|
||||
};
|
||||
const flipFilter = flipMap[orientation];
|
||||
|
||||
const args = ['-i', inputName];
|
||||
if (flipFilter) {
|
||||
args.push('-vf', flipFilter);
|
||||
}
|
||||
|
||||
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
|
||||
|
||||
await ffmpeg.exec(args);
|
||||
|
||||
const flippedData = await ffmpeg.readFile(outputName);
|
||||
return new File(
|
||||
[new Blob([flippedData], { type: 'video/mp4' })],
|
||||
`${input.name.replace(/\.[^/.]+$/, '')}_flipped.mp4`,
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
}
|
5
src/pages/tools/video/flip/types.ts
Normal file
5
src/pages/tools/video/flip/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type FlipOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
export type InitialValuesType = {
|
||||
orientation: FlipOrientation;
|
||||
};
|
@@ -41,14 +41,27 @@ export default function ChangeSpeed({ title }: ToolComponentProps) {
|
||||
try {
|
||||
await ffmpeg.writeFile('input.gif', await fetchFile(file));
|
||||
|
||||
// Use FFmpeg's setpts filter to change the speed
|
||||
// PTS (Presentation Time Stamp) determines when each frame is shown
|
||||
// 1/speed changes the PTS - lower value = faster playback
|
||||
// Process the GIF to change playback speed while preserving quality
|
||||
// The filter_complex does three main operations:
|
||||
// 1. [0:v]setpts=${1/newSpeed}*PTS - Adjusts frame timing:
|
||||
// - PTS (Presentation Time Stamp) controls when each frame is displayed
|
||||
// - Dividing by speed factor (e.g., 2 for 2x speed) reduces display time
|
||||
// - Example: 1/2 = 0.5 → frames show for half their normal duration
|
||||
// 2. split[a][b] - Creates two identical streams for parallel processing:
|
||||
// - [a] goes to palettegen to create an optimized color palette
|
||||
// - [b] contains the speed-adjusted frames
|
||||
// 3. [b][p]paletteuse - Applies the generated palette to maintain:
|
||||
// - Color accuracy
|
||||
// - Transparency handling
|
||||
// - Reduced file size
|
||||
// This approach prevents visual artifacts that occur with simple re-encoding
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
'input.gif',
|
||||
'-filter:v',
|
||||
`setpts=${1 / newSpeed}*PTS`,
|
||||
'-filter_complex',
|
||||
`[0:v]setpts=${
|
||||
1 / newSpeed
|
||||
}*PTS,split[a][b];[a]palettegen[p];[b][p]paletteuse`,
|
||||
'-f',
|
||||
'gif',
|
||||
'output.gif'
|
||||
|
@@ -1,7 +1,20 @@
|
||||
import { tool as videoChangeSpeed } from './change-speed/meta';
|
||||
import { tool as videoFlip } from './flip/meta';
|
||||
import { rotate } from '../string/rotate/service';
|
||||
import { gifTools } from './gif';
|
||||
import { tool as trimVideo } from './trim/meta';
|
||||
import { tool as rotateVideo } from './rotate/meta';
|
||||
import { tool as compressVideo } from './compress/meta';
|
||||
import { tool as loopVideo } from './loop/meta';
|
||||
import { tool as flipVideo } from './flip/meta';
|
||||
import { tool as changeSpeed } from './change-speed/meta';
|
||||
|
||||
export const videoTools = [...gifTools, trimVideo, rotateVideo, compressVideo];
|
||||
export const videoTools = [
|
||||
...gifTools,
|
||||
trimVideo,
|
||||
rotateVideo,
|
||||
compressVideo,
|
||||
loopVideo,
|
||||
flipVideo,
|
||||
changeSpeed
|
||||
];
|
||||
|
89
src/pages/tools/video/loop/index.tsx
Normal file
89
src/pages/tools/video/loop/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { loopVideo } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
import ToolVideoInput from '@components/input/ToolVideoInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { updateNumberField } from '@utils/string';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
loops: 2
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
loops: Yup.number().min(1, 'Number of loops must be greater than 1')
|
||||
});
|
||||
|
||||
export default function Loop({ title, longDescription }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||
if (!input) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const resultFile = await loopVideo(input, values);
|
||||
await setResult(resultFile);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Loops',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'loops', updateField)
|
||||
}
|
||||
value={values.loops}
|
||||
label={'Number of Loops'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={<ToolVideoInput value={input} onChange={setInput} />}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult
|
||||
value={null}
|
||||
title={'Looping Video'}
|
||||
loading={true}
|
||||
extension={''}
|
||||
/>
|
||||
) : (
|
||||
<ToolFileResult
|
||||
value={result}
|
||||
title={'Looped Video'}
|
||||
extension={'mp4'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/video/loop/meta.ts
Normal file
13
src/pages/tools/video/loop/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Loop Video',
|
||||
path: 'loop',
|
||||
icon: 'ic:baseline-loop',
|
||||
description:
|
||||
'This online utility lets you loop videos by specifying the number of repetitions. You can preview the looped video before processing. Supports common video formats like MP4, WebM, and OGG.',
|
||||
shortDescription: 'Loop videos multiple times',
|
||||
keywords: ['loop', 'video', 'repeat', 'duplicate', 'sequence', 'playback'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
41
src/pages/tools/video/loop/service.ts
Normal file
41
src/pages/tools/video/loop/service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
export async function loopVideo(
|
||||
input: File,
|
||||
options: InitialValuesType
|
||||
): Promise<File> {
|
||||
if (!ffmpeg.loaded) {
|
||||
await ffmpeg.load({
|
||||
wasmURL:
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||
});
|
||||
}
|
||||
|
||||
const inputName = 'input.mp4';
|
||||
const outputName = 'output.mp4';
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(input));
|
||||
|
||||
const args = [];
|
||||
const loopCount = options.loops - 1;
|
||||
|
||||
if (loopCount <= 0) {
|
||||
return input;
|
||||
}
|
||||
|
||||
args.push('-stream_loop', loopCount.toString());
|
||||
args.push('-i', inputName);
|
||||
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
|
||||
|
||||
await ffmpeg.exec(args);
|
||||
|
||||
const loopedData = await ffmpeg.readFile(outputName);
|
||||
return await new File(
|
||||
[new Blob([loopedData], { type: 'video/mp4' })],
|
||||
`${input.name.replace(/\.[^/.]+$/, '')}_looped.mp4`,
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
}
|
3
src/pages/tools/video/loop/types.ts
Normal file
3
src/pages/tools/video/loop/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
loops: number;
|
||||
};
|
@@ -81,7 +81,6 @@ export default function RotateVideo({ title }: ToolComponentProps) {
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
}
|
||||
|
@@ -116,7 +116,6 @@ export default function TrimVideo({ title }: ToolComponentProps) {
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
showTrimControls={true}
|
||||
onTrimChange={(trimStart, trimEnd) => {
|
||||
|
@@ -2,7 +2,7 @@ import ToolLayout from '../components/ToolLayout';
|
||||
import React, { JSXElementConstructor, LazyExoticComponent } from 'react';
|
||||
import { IconifyIcon } from '@iconify/react';
|
||||
|
||||
interface ToolOptions {
|
||||
export interface ToolMeta {
|
||||
path: string;
|
||||
component: LazyExoticComponent<JSXElementConstructor<ToolComponentProps>>;
|
||||
keywords: string[];
|
||||
@@ -44,7 +44,7 @@ export interface ToolComponentProps {
|
||||
|
||||
export const defineTool = (
|
||||
basePath: ToolCategory,
|
||||
options: ToolOptions
|
||||
options: ToolMeta
|
||||
): DefinedTool => {
|
||||
const {
|
||||
icon,
|
||||
|
Reference in New Issue
Block a user