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 { Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
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 () => {
|
type OutputFormat = "png" | "jpg" | "webp" | "avif" | "tiff" | "gif" | "heif";
|
||||||
if (files.length === 0) return;
|
type SizingMode = "scale" | "custom";
|
||||||
|
type BgMode = "transparent" | "color";
|
||||||
|
|
||||||
setProcessing(true);
|
const FORMATS: OutputFormat[] = ["png", "jpg", "webp", "avif", "tiff", "gif", "heif"];
|
||||||
setError(null);
|
const LOSSY_FORMATS: OutputFormat[] = ["jpg", "webp", "avif", "heif"];
|
||||||
setDownloadUrl(null);
|
const NO_TRANSPARENCY_FORMATS: OutputFormat[] = ["jpg", "tiff"];
|
||||||
|
|
||||||
try {
|
const SCALE_PRESETS = [0.5, 1, 2, 3, 4];
|
||||||
const formData = new FormData();
|
const DPI_PRESETS = [72, 96, 150, 300];
|
||||||
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 res = await fetch("/api/v1/tools/svg-to-raster", {
|
interface SvgDims {
|
||||||
method: "POST",
|
width: number;
|
||||||
headers: formatHeaders(),
|
height: number;
|
||||||
body: formData,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
function parseSvgDimensions(file: File): Promise<SvgDims | null> {
|
||||||
const body = await res.json().catch(() => ({}));
|
return new Promise((resolve) => {
|
||||||
throw new Error(body.error || `Failed: ${res.status}`);
|
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();
|
export function SvgToRasterSettings() {
|
||||||
setJobId(result.jobId);
|
const { files } = useFileStore();
|
||||||
setProcessedUrl(result.downloadUrl);
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
setDownloadUrl(result.downloadUrl);
|
useToolProcessor("svg-to-raster");
|
||||||
setOriginalSize(result.originalSize);
|
|
||||||
setProcessedSize(result.processedSize);
|
// SVG intrinsic dimensions
|
||||||
setSizes(result.originalSize, result.processedSize);
|
const [svgDims, setSvgDims] = useState<SvgDims | null>(null);
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Conversion failed");
|
// Sizing
|
||||||
} finally {
|
const [sizingMode, setSizingMode] = useState<SizingMode>("scale");
|
||||||
setProcessing(false);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
{/* SVG Info */}
|
||||||
<div className="flex-1">
|
{hasFile && svgDims && (
|
||||||
<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 && (
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="svg-raster-bg-color" className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">SVG Dimensions</p>
|
||||||
Background Color
|
<div className="mt-1 px-2 py-1.5 rounded bg-muted text-sm text-foreground font-mono">
|
||||||
</label>
|
{svgDims.width} x {svgDims.height}
|
||||||
<input
|
{sizingMode === "scale" && computedWidth && computedHeight && (
|
||||||
id="svg-raster-bg-color"
|
<span className="text-muted-foreground">
|
||||||
type="color"
|
{" "}
|
||||||
value={backgroundColor.slice(0, 7)}
|
→ {computedWidth} x {computedHeight}
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
</span>
|
||||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
)}
|
||||||
/>
|
</div>
|
||||||
</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>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
{originalSize != null && processedSize != null && (
|
{/* Process / Progress */}
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
{processing ? (
|
||||||
<p>SVG: {(originalSize / 1024).toFixed(1)} KB</p>
|
<ProgressCard
|
||||||
<p>Output: {(processedSize / 1024).toFixed(1)} KB</p>
|
active={processing}
|
||||||
</div>
|
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
|
{/* Download */}
|
||||||
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>
|
|
||||||
|
|
||||||
{downloadUrl && (
|
{downloadUrl && (
|
||||||
<a
|
<a
|
||||||
href={downloadUrl}
|
href={downloadUrl}
|
||||||
|
|||||||
Reference in New Issue
Block a user