diff --git a/apps/web/src/components/tools/svg-to-raster-settings.tsx b/apps/web/src/components/tools/svg-to-raster-settings.tsx index eed82050..d2b1b1fb 100644 --- a/apps/web/src/components/tools/svg-to-raster-settings.tsx +++ b/apps/web/src/components/tools/svg-to-raster-settings.tsx @@ -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(null); - const [originalSize, setOriginalSize] = useState(null); - const [processedSize, setProcessedSize] = useState(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 = { - 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 { + 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(null); + + // Sizing + const [sizingMode, setSizingMode] = useState("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("png"); + const [quality, setQuality] = useState(90); + + // Background + const [bgMode, setBgMode] = useState("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 = { + 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 (
-
-
- - 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" - /> -
-
- - 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" - /> -
-
- -
- - -
- - - - {!transparent && ( + {/* SVG Info */} + {hasFile && svgDims && (
- - setBackgroundColor(e.target.value)} - className="w-full mt-0.5 h-8 rounded border border-border" - /> +

SVG Dimensions

+
+ {svgDims.width} x {svgDims.height} + {sizingMode === "scale" && computedWidth && computedHeight && ( + + {" "} + → {computedWidth} x {computedHeight} + + )} +
)} + {hasFile && !svgDims && ( +
+

SVG Dimensions

+
+ Could not detect dimensions. Using custom size. +
+
+ )} + +
+ + {/* Sizing Mode */} +
+

Sizing

+
+ + +
+
+ + {/* Scale controls */} + {sizingMode === "scale" && svgDims && ( +
+
+ {SCALE_PRESETS.map((s) => ( + + ))} +
+
+
+ Scale + {scale}x +
+ setScale(Number(e.target.value))} + className="w-full mt-1" + /> +
+
+ )} + + {/* Custom size controls */} + {sizingMode === "custom" && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ )} + +
+ + {/* Render DPI */} +
+

Render DPI

+
+ {DPI_PRESETS.map((d) => ( + + ))} +
+
+ +
+ + {/* Format */} +
+

Format

+
+ {FORMATS.map((f) => ( + + ))} +
+
+ + {/* Quality (lossy only) */} + {isLossy && ( + <> +
+
+
+ Quality + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + )} + +
+ + {/* Background */} +
+

Background

+
+ + +
+
+ + {bgMode === "color" && ( +
+
+ )} + + {/* Error */} {error &&

{error}

} - {originalSize != null && processedSize != null && ( -
-

SVG: {(originalSize / 1024).toFixed(1)} KB

-

Output: {(processedSize / 1024).toFixed(1)} KB

-
+ {/* Process / Progress */} + {processing ? ( + 1 ? `Converting ${files.length} files` : "Converting SVG"} + stage={progress.stage} + percent={progress.percent} + elapsed={progress.elapsed} + /> + ) : ( + )} - - + {/* Download */} {downloadUrl && (