feat(svg-to-raster): rewrite settings UI with modern controls and batch support

Switch from manual fetch to useToolProcessor hook for consistency and
batch support. Add scale/custom sizing modes, DPI presets, 7-format
button grid, quality slider, transparent/color background toggle with
preset swatches, and ProgressCard during processing.
This commit is contained in:
Siddharth Kumar Sah
2026-04-13 12:47:28 +08:00
parent 07f95e988f
commit 43c41755cc
@@ -1,158 +1,371 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { formatHeaders } from "@/lib/api";
import { Download } from "lucide-react";
import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function SvgToRasterSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
const [width, setWidth] = useState(1024);
const [height, setHeight] = useState("");
const [backgroundColor, setBackgroundColor] = useState("#00000000");
const [outputFormat, setOutputFormat] = useState<"png" | "jpg" | "webp">("png");
const [transparent, setTransparent] = useState(true);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = useState<number | null>(null);
const [processedSize, setProcessedSize] = useState<number | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
type OutputFormat = "png" | "jpg" | "webp" | "avif" | "tiff" | "gif" | "heif";
type SizingMode = "scale" | "custom";
type BgMode = "transparent" | "color";
setProcessing(true);
setError(null);
setDownloadUrl(null);
const FORMATS: OutputFormat[] = ["png", "jpg", "webp", "avif", "tiff", "gif", "heif"];
const LOSSY_FORMATS: OutputFormat[] = ["jpg", "webp", "avif", "heif"];
const NO_TRANSPARENCY_FORMATS: OutputFormat[] = ["jpg", "tiff"];
try {
const formData = new FormData();
formData.append("file", files[0]);
const settings: Record<string, unknown> = {
width,
outputFormat,
backgroundColor: transparent ? "#00000000" : backgroundColor,
};
if (height) settings.height = Number(height);
formData.append("settings", JSON.stringify(settings));
const SCALE_PRESETS = [0.5, 1, 2, 3, 4];
const DPI_PRESETS = [72, 96, 150, 300];
const res = await fetch("/api/v1/tools/svg-to-raster", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
interface SvgDims {
width: number;
height: number;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
function parseSvgDimensions(file: File): Promise<SvgDims | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
const vbMatch = text.match(
/viewBox=["'][\s]*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)[\s]*["']/,
);
if (vbMatch) {
const w = parseFloat(vbMatch[3]);
const h = parseFloat(vbMatch[4]);
if (w > 0 && h > 0) return resolve({ width: Math.round(w), height: Math.round(h) });
}
const wMatch = text.match(/\bwidth=["']([0-9.]+)/);
const hMatch = text.match(/\bheight=["']([0-9.]+)/);
if (wMatch && hMatch) {
const w = parseFloat(wMatch[1]);
const h = parseFloat(hMatch[1]);
if (w > 0 && h > 0) return resolve({ width: Math.round(w), height: Math.round(h) });
}
resolve(null);
};
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
}
const result = await res.json();
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setDownloadUrl(result.downloadUrl);
setOriginalSize(result.originalSize);
setProcessedSize(result.processedSize);
setSizes(result.originalSize, result.processedSize);
} catch (err) {
setError(err instanceof Error ? err.message : "Conversion failed");
} finally {
setProcessing(false);
export function SvgToRasterSettings() {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("svg-to-raster");
// SVG intrinsic dimensions
const [svgDims, setSvgDims] = useState<SvgDims | null>(null);
// Sizing
const [sizingMode, setSizingMode] = useState<SizingMode>("scale");
const [scale, setScale] = useState(1);
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState("");
// Render
const [dpi, setDpi] = useState(300);
const [format, setFormat] = useState<OutputFormat>("png");
const [quality, setQuality] = useState(90);
// Background
const [bgMode, setBgMode] = useState<BgMode>("transparent");
const [bgColor, setBgColor] = useState("#ffffff");
const isLossy = LOSSY_FORMATS.includes(format);
const hasFile = files.length > 0;
// Parse SVG dimensions when file changes
useEffect(() => {
if (files.length === 0) {
setSvgDims(null);
return;
}
parseSvgDimensions(files[0]).then((dims) => {
setSvgDims(dims);
if (!dims) setSizingMode("custom");
});
}, [files]);
// When format changes to one that does not support transparency, switch bgMode
useEffect(() => {
if (NO_TRANSPARENCY_FORMATS.includes(format) && bgMode === "transparent") {
setBgMode("color");
}
}, [format, bgMode]);
const computedWidth = svgDims ? Math.round(svgDims.width * scale) : null;
const computedHeight = svgDims ? Math.round(svgDims.height * scale) : null;
const handleProcess = () => {
const settings: Record<string, unknown> = {
dpi,
quality,
outputFormat: format,
backgroundColor: bgMode === "transparent" ? "#00000000" : bgColor,
};
if (sizingMode === "scale" && computedWidth && computedHeight) {
settings.width = computedWidth;
settings.height = computedHeight;
} else if (sizingMode === "custom") {
settings.width = customWidth;
if (customHeight) settings.height = Number(customHeight);
}
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const btnClass = (active: boolean) =>
`text-xs py-1.5 rounded ${active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="svg-raster-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="svg-raster-width"
type="number"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
min={1}
max={8192}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex-1">
<label htmlFor="svg-raster-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="svg-raster-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
<div>
<label htmlFor="svg-raster-format" className="text-xs text-muted-foreground">
Output Format
</label>
<select
id="svg-raster-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="png">PNG</option>
<option value="jpg">JPEG</option>
<option value="webp">WebP</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={transparent}
onChange={(e) => setTransparent(e.target.checked)}
disabled={outputFormat === "jpg"}
className="rounded"
/>
Transparent background
</label>
{!transparent && (
{/* SVG Info */}
{hasFile && svgDims && (
<div>
<label htmlFor="svg-raster-bg-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="svg-raster-bg-color"
type="color"
value={backgroundColor.slice(0, 7)}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
/>
<p className="text-xs text-muted-foreground">SVG Dimensions</p>
<div className="mt-1 px-2 py-1.5 rounded bg-muted text-sm text-foreground font-mono">
{svgDims.width} x {svgDims.height}
{sizingMode === "scale" && computedWidth && computedHeight && (
<span className="text-muted-foreground">
{" "}
&rarr; {computedWidth} x {computedHeight}
</span>
)}
</div>
</div>
)}
{hasFile && !svgDims && (
<div>
<p className="text-xs text-muted-foreground">SVG Dimensions</p>
<div className="mt-1 px-2 py-1.5 rounded bg-muted text-xs text-muted-foreground">
Could not detect dimensions. Using custom size.
</div>
</div>
)}
<div className="border-t border-border" />
{/* Sizing Mode */}
<div>
<p className="text-xs text-muted-foreground">Sizing</p>
<div className="grid grid-cols-2 gap-1 mt-1">
<button
type="button"
onClick={() => svgDims && setSizingMode("scale")}
disabled={!svgDims}
className={btnClass(sizingMode === "scale")}
>
Scale Factor
</button>
<button
type="button"
onClick={() => setSizingMode("custom")}
className={btnClass(sizingMode === "custom")}
>
Custom Size
</button>
</div>
</div>
{/* Scale controls */}
{sizingMode === "scale" && svgDims && (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-1">
{SCALE_PRESETS.map((s) => (
<button
type="button"
key={s}
onClick={() => setScale(s)}
className={btnClass(scale === s)}
>
{s}x
</button>
))}
</div>
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Scale</span>
<span className="text-xs font-mono text-foreground">{scale}x</span>
</div>
<input
type="range"
min={0.25}
max={8}
step={0.25}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
</div>
)}
{/* Custom size controls */}
{sizingMode === "custom" && (
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="svg-custom-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="svg-custom-width"
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(Number(e.target.value))}
min={1}
max={16384}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex-1">
<label htmlFor="svg-custom-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="svg-custom-height"
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
)}
<div className="border-t border-border" />
{/* Render DPI */}
<div>
<p className="text-xs text-muted-foreground">Render DPI</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{DPI_PRESETS.map((d) => (
<button type="button" key={d} onClick={() => setDpi(d)} className={btnClass(dpi === d)}>
{d}
</button>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Format */}
<div>
<p className="text-xs text-muted-foreground">Format</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{FORMATS.map((f) => (
<button
type="button"
key={f}
onClick={() => setFormat(f)}
className={`uppercase ${btnClass(format === f)}`}
>
{f}
</button>
))}
</div>
</div>
{/* Quality (lossy only) */}
{isLossy && (
<>
<div className="border-t border-border" />
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Quality</span>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
</>
)}
<div className="border-t border-border" />
{/* Background */}
<div>
<p className="text-xs text-muted-foreground">Background</p>
<div className="grid grid-cols-2 gap-1 mt-1">
<button
type="button"
onClick={() => !NO_TRANSPARENCY_FORMATS.includes(format) && setBgMode("transparent")}
disabled={NO_TRANSPARENCY_FORMATS.includes(format)}
className={btnClass(bgMode === "transparent")}
>
Transparent
</button>
<button
type="button"
onClick={() => setBgMode("color")}
className={btnClass(bgMode === "color")}
>
Color
</button>
</div>
</div>
{bgMode === "color" && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setBgColor("#ffffff")}
className={`w-8 h-8 rounded border-2 bg-white ${bgColor === "#ffffff" ? "border-primary" : "border-border"}`}
aria-label="White background"
/>
<button
type="button"
onClick={() => setBgColor("#000000")}
className={`w-8 h-8 rounded border-2 bg-black ${bgColor === "#000000" ? "border-primary" : "border-border"}`}
aria-label="Black background"
/>
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="h-8 w-8 rounded border border-border cursor-pointer"
/>
<span className="text-xs font-mono text-muted-foreground">{bgColor}</span>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>SVG: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Output: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
{/* Process / Progress */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={files.length > 1 ? `Converting ${files.length} files` : "Converting SVG"}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="svg-to-raster-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Convert (${files.length} files)` : "Convert SVG"}
</button>
)}
<button
type="button"
data-testid="svg-to-raster-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Converting..." : "Convert SVG"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}