[dyad] Refactored image converter - wrote 8 file(s)
This commit is contained in:
350
src/hooks/use-image-converter.ts
Normal file
350
src/hooks/use-image-converter.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, ChangeEvent, useEffect, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ScaleMode = 'fill' | 'cover' | 'contain';
|
||||
export type ImageFormat = "png" | "jpeg" | "webp";
|
||||
|
||||
export function useImageConverter() {
|
||||
const t = useTranslations("ImageConverter");
|
||||
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||
const [filenames, setFilenames] = useState<string[]>([]);
|
||||
const [width, setWidth] = useState<number | string>("");
|
||||
const [height, setHeight] = useState<number | string>("");
|
||||
const [aspectRatio, setAspectRatio] = useState<string>("custom");
|
||||
const [keepOrientation, setKeepOrientation] = useState<boolean>(true);
|
||||
const [format, setFormat] = useState<ImageFormat>("webp");
|
||||
const [quality, setQuality] = useState<number>(90);
|
||||
|
||||
const [prefix, setPrefix] = useState<string>("");
|
||||
const [suffix, setSuffix] = useState<string>("");
|
||||
const [useCounter, setUseCounter] = useState<boolean>(false);
|
||||
const [counterStart, setCounterStart] = useState<number>(1);
|
||||
const [counterDigits, setCounterDigits] = useState<number>(3);
|
||||
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(false);
|
||||
const [defaultBaseName, setDefaultBaseName] = useState<string>("");
|
||||
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>('cover');
|
||||
const [objectPosition, setObjectPosition] = useState<string>('center center');
|
||||
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [previewUrls]);
|
||||
|
||||
const handleFiles = useCallback((files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const imageFiles = Array.from(files).filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
toast.error(t("toasts.noValidImages"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImages((prev) => [...prev, ...imageFiles]);
|
||||
setPreviewUrls((prev) => [...prev, ...imageFiles.map((file) => URL.createObjectURL(file))]);
|
||||
setFilenames((prev) => [
|
||||
...prev,
|
||||
...imageFiles.map((file) =>
|
||||
useDefaultBaseName && defaultBaseName
|
||||
? defaultBaseName
|
||||
: file.name.substring(0, file.name.lastIndexOf("."))
|
||||
),
|
||||
]);
|
||||
|
||||
toast.success(t("toasts.imagesAdded", { count: imageFiles.length }));
|
||||
}, [defaultBaseName, useDefaultBaseName, t]);
|
||||
|
||||
const handleRemoveImage = (indexToRemove: number) => {
|
||||
URL.revokeObjectURL(previewUrls[indexToRemove]);
|
||||
setImages((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||
setPreviewUrls((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||
setFilenames((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
setImages([]);
|
||||
setPreviewUrls([]);
|
||||
setFilenames([]);
|
||||
toast.info(t("toasts.allCleared"));
|
||||
};
|
||||
|
||||
const handleFilenameChange = (index: number, newName: string) => {
|
||||
setFilenames((prev) => {
|
||||
const newFilenames = [...prev];
|
||||
newFilenames[index] = newName;
|
||||
return newFilenames;
|
||||
});
|
||||
};
|
||||
|
||||
const generateFinalFilename = useCallback((index: number) => {
|
||||
const baseName = filenames[index] || "filename";
|
||||
let finalName = `${prefix}${baseName}${suffix}`;
|
||||
|
||||
if (useCounter) {
|
||||
const counter = (index + counterStart).toString().padStart(counterDigits, '0');
|
||||
finalName += `${counter}`;
|
||||
}
|
||||
|
||||
return finalName;
|
||||
}, [filenames, prefix, suffix, useCounter, counterStart, counterDigits]);
|
||||
|
||||
const convertAndDownload = useCallback((image: File, previewUrl: string, index: number) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = previewUrl;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
let targetWidth = width ? Number(width) : img.naturalWidth;
|
||||
let targetHeight = height ? Number(height) : img.naturalHeight;
|
||||
|
||||
if (keepOrientation && width && height) {
|
||||
const isOriginalPortrait = img.naturalHeight > img.naturalWidth;
|
||||
const isTargetPortrait = Number(height) > Number(width);
|
||||
if (isOriginalPortrait !== isTargetPortrait) {
|
||||
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (ctx) {
|
||||
const sWidth = img.naturalWidth;
|
||||
const sHeight = img.naturalHeight;
|
||||
const dWidth = targetWidth;
|
||||
const dHeight = targetHeight;
|
||||
|
||||
if (scaleMode === 'fill' || !width || !height) {
|
||||
ctx.drawImage(img, 0, 0, dWidth, dHeight);
|
||||
} else {
|
||||
const sourceRatio = sWidth / sHeight;
|
||||
const targetRatio = dWidth / dHeight;
|
||||
let sx = 0, sy = 0, sRenderWidth = sWidth, sRenderHeight = sHeight;
|
||||
let dx = 0, dy = 0, dRenderWidth = dWidth, dRenderHeight = dHeight;
|
||||
const [hPos, vPos] = objectPosition.split(' ');
|
||||
|
||||
if (scaleMode === 'cover') {
|
||||
if (sourceRatio > targetRatio) {
|
||||
sRenderHeight = sHeight;
|
||||
sRenderWidth = sHeight * targetRatio;
|
||||
if (hPos === 'center') sx = (sWidth - sRenderWidth) / 2;
|
||||
if (hPos === 'right') sx = sWidth - sRenderWidth;
|
||||
} else {
|
||||
sRenderWidth = sWidth;
|
||||
sRenderHeight = sWidth / targetRatio;
|
||||
if (vPos === 'center') sy = (sHeight - sRenderHeight) / 2;
|
||||
if (vPos === 'bottom') sy = sHeight - sRenderHeight;
|
||||
}
|
||||
ctx.drawImage(img, sx, sy, sRenderWidth, sRenderHeight, 0, 0, dWidth, dHeight);
|
||||
} else if (scaleMode === 'contain') {
|
||||
if (sourceRatio > targetRatio) {
|
||||
dRenderWidth = dWidth;
|
||||
dRenderHeight = dWidth / sourceRatio;
|
||||
if (vPos === 'center') dy = (dHeight - dRenderHeight) / 2;
|
||||
if (vPos === 'bottom') dy = dHeight - dRenderHeight;
|
||||
} else {
|
||||
dRenderHeight = dHeight;
|
||||
dRenderWidth = dHeight * sourceRatio;
|
||||
if (hPos === 'center') dx = (dWidth - dRenderWidth) / 2;
|
||||
if (hPos === 'right') dx = dWidth - dRenderWidth;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, sWidth, sHeight, dx, dy, dRenderWidth, dRenderHeight);
|
||||
}
|
||||
}
|
||||
|
||||
const mimeType = `image/${format}`;
|
||||
const dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality / 100);
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
|
||||
const dimensionSuffix = width && height ? `_${targetWidth}x${targetHeight}` : '';
|
||||
link.download = `${generateFinalFilename(index)}${dimensionSuffix}.${format}`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Could not process ${image.name}.`));
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
reject(new Error(`Failed to load ${image.name} for conversion.`));
|
||||
};
|
||||
});
|
||||
}, [width, height, keepOrientation, scaleMode, objectPosition, format, quality, generateFinalFilename]);
|
||||
|
||||
const handleConvertAndDownloadAll = async () => {
|
||||
if (images.length === 0) {
|
||||
toast.error(t("toasts.noImages"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConverting(true);
|
||||
toast.info(t("toasts.conversionStarting", { count: images.length }));
|
||||
|
||||
const conversionPromises = images.map((image, index) =>
|
||||
convertAndDownload(image, previewUrls[index], index)
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(conversionPromises);
|
||||
toast.success(t("toasts.conversionSuccess", { count: images.length }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error(t("toasts.conversionError"));
|
||||
}
|
||||
} finally {
|
||||
setIsConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertAndDownloadSingle = async (index: number) => {
|
||||
setConvertingIndex(index);
|
||||
toast.info(t("toasts.singleConversionStarting", { filename: filenames[index] }));
|
||||
|
||||
try {
|
||||
await convertAndDownload(images[index], previewUrls[index], index);
|
||||
toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error(t("toasts.conversionError"));
|
||||
}
|
||||
} finally {
|
||||
setConvertingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplySettings = () => {
|
||||
toast.info(t("toasts.settingsApplied"));
|
||||
};
|
||||
|
||||
const handleAspectRatioChange = (value: string) => {
|
||||
setAspectRatio(value);
|
||||
|
||||
if (value === "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
const [w, h] = value.split("/").map(Number);
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
if (w > h) {
|
||||
newWidth = 1000;
|
||||
newHeight = Math.round((1000 * h) / w);
|
||||
} else if (h > w) {
|
||||
newHeight = 1000;
|
||||
newWidth = Math.round((1000 * w) / h);
|
||||
} else {
|
||||
newWidth = 1000;
|
||||
newHeight = 1000;
|
||||
}
|
||||
|
||||
setWidth(newWidth);
|
||||
setHeight(newHeight);
|
||||
};
|
||||
|
||||
const handleWidthChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWidth(e.target.value);
|
||||
setAspectRatio("custom");
|
||||
};
|
||||
|
||||
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHeight(e.target.value);
|
||||
setAspectRatio("custom");
|
||||
};
|
||||
|
||||
const handleSwapDimensions = () => {
|
||||
setWidth(height);
|
||||
setHeight(width);
|
||||
};
|
||||
|
||||
const handleApplyDefaultBaseNameToAll = () => {
|
||||
if (!defaultBaseName) {
|
||||
toast.error(t("toasts.noDefaultBaseName"));
|
||||
return;
|
||||
}
|
||||
if (images.length === 0) {
|
||||
toast.info(t("toasts.uploadImagesFirst"));
|
||||
return;
|
||||
}
|
||||
setFilenames((prev) => prev.map(() => defaultBaseName));
|
||||
toast.success(t("toasts.baseNameApplied", { baseName: defaultBaseName, count: images.length }));
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
images,
|
||||
previewUrls,
|
||||
filenames,
|
||||
width,
|
||||
height,
|
||||
aspectRatio,
|
||||
keepOrientation,
|
||||
format,
|
||||
quality,
|
||||
prefix,
|
||||
suffix,
|
||||
useCounter,
|
||||
counterStart,
|
||||
counterDigits,
|
||||
useDefaultBaseName,
|
||||
defaultBaseName,
|
||||
scaleMode,
|
||||
objectPosition,
|
||||
isConverting,
|
||||
convertingIndex,
|
||||
fileInputRef,
|
||||
setWidth,
|
||||
setHeight,
|
||||
setAspectRatio,
|
||||
setKeepOrientation,
|
||||
setFormat,
|
||||
setQuality,
|
||||
setPrefix,
|
||||
setSuffix,
|
||||
setUseCounter,
|
||||
setCounterStart,
|
||||
setCounterDigits,
|
||||
setUseDefaultBaseName,
|
||||
setDefaultBaseName,
|
||||
setScaleMode,
|
||||
setObjectPosition,
|
||||
handleFiles,
|
||||
handleRemoveImage,
|
||||
handleClearAll,
|
||||
handleFilenameChange,
|
||||
generateFinalFilename,
|
||||
handleConvertAndDownloadAll,
|
||||
handleConvertAndDownloadSingle,
|
||||
handleApplySettings,
|
||||
handleAspectRatioChange,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
handleSwapDimensions,
|
||||
handleApplyDefaultBaseNameToAll,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user