mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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">
|
||||
{" "}
|
||||
→ {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}
|
||||
|
||||
Reference in New Issue
Block a user