Merge branch 'main' into chesterkxng

This commit is contained in:
Chesterkxng
2025-05-24 02:57:17 +02:00
57 changed files with 6574 additions and 2493 deletions

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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={{

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,14 @@
export type InitialValuesType = {
vars: {
[key: string]: {
value: number;
unit: string;
};
};
// Track preset selections
presets: {
[key: string]: string;
};
outputVariable: string;
};

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,8 @@
import { InitialValuesType } from './types';
export function main(
input: File | null,
options: InitialValuesType
): File | null {
return input;
}

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
newSpeed: number;
};

View File

@@ -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'}
/>
}

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

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

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

View File

@@ -0,0 +1,5 @@
export type FlipOrientation = 'horizontal' | 'vertical';
export type InitialValuesType = {
orientation: FlipOrientation;
};

View File

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

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
loops: number;
};

View File

@@ -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'}
/>
}

View File

@@ -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) => {

View File

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