Skip to content

Commit 218ccad

Browse files
authored
feat(tools): add SVG to React/CSS utility
* feat(tools): add SVG to React/CSS utility Add SVG converter tool that transforms raw SVG code into optimized variants: - React Component: Clean, reusable functional component with dynamic sizing - CSS Data URI: Inline SVG for CSS background images - CSS Mask: SVG as CSS mask-image for flexible icon styling Features: - Automatic metadata cleanup (removes XML declarations, comments, DOCTYPE) - viewBox preservation for responsive sizing - Optional currentColor replacement for theme support - Configurable default width/height - File upload support (.svg files) - Copy-to-clipboard functionality Closes #50
1 parent 743674b commit 218ccad

3 files changed

Lines changed: 497 additions & 0 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
"use client";
2+
import React, { useMemo, useRef, useState } from "react";
3+
import DevelopmentToolsStyles from "../../developmentToolsStyles.module.scss";
4+
5+
const SvgConverter = () => {
6+
const [svgInput, setSvgInput] = useState("");
7+
const [outputFormat, setOutputFormat] = useState<"react" | "css-data-uri" | "css-mask">("react");
8+
const [defaultWidth, setDefaultWidth] = useState("24");
9+
const [defaultHeight, setDefaultHeight] = useState("24");
10+
const [useCurrentColor, setUseCurrentColor] = useState(true);
11+
const [error, setError] = useState<string | null>(null);
12+
const fileInputRef = useRef<HTMLInputElement | null>(null);
13+
14+
// Clean SVG by removing metadata and unnecessary attributes
15+
const cleanSvg = (svg: string): string => {
16+
try {
17+
let cleaned = svg.trim();
18+
// Remove XML declaration if present
19+
cleaned = cleaned.replace(/<\?xml[^?]*\?>/g, "");
20+
// Remove comments
21+
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, "");
22+
// Remove DOCTYPE
23+
cleaned = cleaned.replace(/<!DOCTYPE[^>]*>/g, "");
24+
// Remove unnecessary whitespace between tags
25+
cleaned = cleaned.replace(/>\s+</g, "><");
26+
// Remove trailing whitespace
27+
cleaned = cleaned.trim();
28+
return cleaned;
29+
} catch {
30+
return svg;
31+
}
32+
};
33+
34+
// Normalize SVG attributes to JSX equivalents
35+
const normalizeSvgAttributes = (svg: string): string => {
36+
let normalized = svg;
37+
// Convert SVG attribute names to JSX equivalents
38+
normalized = normalized.replace(/fill-rule="/g, 'fillRule="');
39+
normalized = normalized.replace(/clip-rule="/g, 'clipRule="');
40+
normalized = normalized.replace(/stroke-linecap="/g, 'strokeLinecap="');
41+
normalized = normalized.replace(/stroke-linejoin="/g, 'strokeLinejoin="');
42+
normalized = normalized.replace(/stroke-miterlimit="/g, 'strokeMiterlimit="');
43+
normalized = normalized.replace(/stroke-width="/g, 'strokeWidth="');
44+
normalized = normalized.replace(/stroke-dasharray="/g, 'strokeDasharray="');
45+
normalized = normalized.replace(/stroke-dashoffset="/g, 'strokeDashoffset="');
46+
normalized = normalized.replace(/text-anchor="/g, 'textAnchor="');
47+
normalized = normalized.replace(/class="/g, 'className="');
48+
return normalized;
49+
};
50+
51+
// Convert SVG for React component output
52+
const generateReactComponent = (svg: string): string => {
53+
try {
54+
const cleaned = cleanSvg(svg);
55+
56+
// Extract viewBox or use defaults
57+
const viewBoxMatch = cleaned.match(/viewBox="([^"]*)"/);
58+
const viewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 24 24";
59+
60+
// Replace default fill/stroke with currentColor if enabled
61+
let reactSvg = cleaned;
62+
if (useCurrentColor) {
63+
// Replace fill colors (except fill="none") with currentColor
64+
reactSvg = reactSvg.replace(/fill="(?!none)[^"]*"/g, 'fill="currentColor"');
65+
// Replace stroke colors with currentColor if they exist
66+
reactSvg = reactSvg.replace(/stroke="[^"]*"/g, 'stroke="currentColor"');
67+
}
68+
69+
// Remove width and height to make it responsive
70+
reactSvg = reactSvg.replace(/\s*width="[^"]*"\s*/g, " ");
71+
reactSvg = reactSvg.replace(/\s*height="[^"]*"\s*/g, " ");
72+
73+
// Normalize SVG attributes to JSX equivalents
74+
reactSvg = normalizeSvgAttributes(reactSvg);
75+
76+
// Clean up multiple spaces
77+
reactSvg = reactSvg.replace(/\s+/g, " ");
78+
79+
const component = `import React from 'react';
80+
81+
interface SvgIconProps {
82+
width?: number | string;
83+
height?: number | string;
84+
className?: string;
85+
}
86+
87+
export const SvgIcon: React.FC<SvgIconProps> = ({
88+
width = ${defaultWidth},
89+
height = ${defaultHeight},
90+
className = ''
91+
}) => (
92+
<svg
93+
width={width}
94+
height={height}
95+
viewBox="${viewBox}"
96+
xmlns="http://www.w3.org/2000/svg"
97+
className={className}
98+
>
99+
${reactSvg.replace(/<svg[^>]*>/g, "").replace(/<\/svg>/g, "").trim()}
100+
</svg>
101+
);
102+
103+
export default SvgIcon;`;
104+
105+
return component;
106+
} catch (e) {
107+
throw new Error("Failed to convert to React component");
108+
}
109+
};
110+
111+
// Convert SVG to CSS Data URI
112+
const generateCssDataUri = (svg: string): string => {
113+
try {
114+
const cleaned = cleanSvg(svg);
115+
116+
// Encode SVG as data URI
117+
// Escape special characters but keep it relatively readable
118+
const encoded = cleaned
119+
.replace(/"/g, "'")
120+
.replace(/%/g, "%25")
121+
.replace(/#/g, "%23")
122+
.replace(/{/g, "%7B")
123+
.replace(/}/g, "%7D")
124+
.replace(/</g, "%3C")
125+
.replace(/>/g, "%3E");
126+
127+
const dataUri = `url("data:image/svg+xml,${encoded}")`;
128+
129+
const css = `.icon {
130+
width: ${defaultWidth}px;
131+
height: ${defaultHeight}px;
132+
background-image: ${dataUri};
133+
background-size: contain;
134+
background-repeat: no-repeat;
135+
background-position: center;
136+
}`;
137+
138+
return css;
139+
} catch (e) {
140+
throw new Error("Failed to convert to CSS Data URI");
141+
}
142+
};
143+
144+
// Convert SVG to CSS Mask
145+
const generateCssMask = (svg: string): string => {
146+
try {
147+
const cleaned = cleanSvg(svg);
148+
149+
const encoded = cleaned
150+
.replace(/"/g, "'")
151+
.replace(/%/g, "%25")
152+
.replace(/#/g, "%23")
153+
.replace(/{/g, "%7B")
154+
.replace(/}/g, "%7D")
155+
.replace(/</g, "%3C")
156+
.replace(/>/g, "%3E");
157+
158+
const dataUri = `url("data:image/svg+xml,${encoded}")`;
159+
160+
const css = `.icon {
161+
width: ${defaultWidth}px;
162+
height: ${defaultHeight}px;
163+
background-color: currentColor;
164+
-webkit-mask-image: ${dataUri};
165+
mask-image: ${dataUri};
166+
-webkit-mask-size: contain;
167+
mask-size: contain;
168+
-webkit-mask-repeat: no-repeat;
169+
mask-repeat: no-repeat;
170+
-webkit-mask-position: center;
171+
mask-position: center;
172+
}`;
173+
174+
return css;
175+
} catch (e) {
176+
throw new Error("Failed to convert to CSS Mask");
177+
}
178+
};
179+
180+
const output = useMemo(() => {
181+
try {
182+
setError(null);
183+
if (!svgInput.trim()) return "";
184+
185+
// Validate SVG
186+
if (!svgInput.includes("<svg")) {
187+
setError("Invalid SVG: must contain <svg> tag");
188+
return "";
189+
}
190+
191+
switch (outputFormat) {
192+
case "react":
193+
return generateReactComponent(svgInput);
194+
case "css-data-uri":
195+
return generateCssDataUri(svgInput);
196+
case "css-mask":
197+
return generateCssMask(svgInput);
198+
default:
199+
return "";
200+
}
201+
} catch (e: any) {
202+
setError(e.message || "Failed to convert SVG");
203+
return "";
204+
}
205+
}, [svgInput, outputFormat, defaultWidth, defaultHeight, useCurrentColor]);
206+
207+
const handleCopy = async () => {
208+
if (!output) return;
209+
try {
210+
await navigator.clipboard.writeText(output);
211+
} catch (_) {}
212+
};
213+
214+
const handleClear = () => {
215+
setSvgInput("");
216+
setError(null);
217+
};
218+
219+
const handlePickFile = () => {
220+
fileInputRef.current?.click();
221+
};
222+
223+
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
224+
const file = e.target.files?.[0];
225+
if (!file) return;
226+
const reader = new FileReader();
227+
reader.onload = () => {
228+
const text = typeof reader.result === "string" ? reader.result : "";
229+
setSvgInput(text);
230+
};
231+
reader.readAsText(file);
232+
e.target.value = "";
233+
};
234+
235+
return (
236+
<section>
237+
<div className="md:mt-8 mt-4">
238+
<div className="flex-1 flex items-center justify-center">
239+
<div className="w-full bg-[#FFFFFF1A] rounded-2xl shadow-lg p-8">
240+
<div className="md:w-[1000px] mx-auto">
241+
{/* Options Section */}
242+
<div className="mb-6 p-4 bg-black/40 rounded-lg border border-white/10">
243+
<h3 className="text-lg font-medium mb-4">Conversion Options</h3>
244+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
245+
<div>
246+
<label className="block text-sm font-medium mb-2">Output Format</label>
247+
<select
248+
value={outputFormat}
249+
onChange={(e) => setOutputFormat(e.target.value as any)}
250+
className="w-full bg-black border border-[#222222] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500"
251+
>
252+
<option value="react">React Component</option>
253+
<option value="css-data-uri">CSS Data URI</option>
254+
<option value="css-mask">CSS Mask</option>
255+
</select>
256+
</div>
257+
258+
<div>
259+
<label className="block text-sm font-medium mb-2">Default Width</label>
260+
<input
261+
type="text"
262+
value={defaultWidth}
263+
onChange={(e) => setDefaultWidth(e.target.value)}
264+
placeholder="24"
265+
className="w-full bg-black border border-[#222222] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500"
266+
/>
267+
</div>
268+
269+
<div>
270+
<label className="block text-sm font-medium mb-2">Default Height</label>
271+
<input
272+
type="text"
273+
value={defaultHeight}
274+
onChange={(e) => setDefaultHeight(e.target.value)}
275+
placeholder="24"
276+
className="w-full bg-black border border-[#222222] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500"
277+
/>
278+
</div>
279+
280+
<div className="flex items-end">
281+
<label className="flex items-center gap-2 mb-2 cursor-pointer w-full">
282+
<input
283+
type="checkbox"
284+
checked={useCurrentColor}
285+
onChange={(e) => setUseCurrentColor(e.target.checked)}
286+
className="w-4 h-4 rounded"
287+
/>
288+
<span className="text-sm font-medium">Use currentColor</span>
289+
</label>
290+
</div>
291+
</div>
292+
</div>
293+
294+
{/* Input/Output Section */}
295+
<div className="flex flex-col lg:flex-row justify-center items-start gap-4 lg:gap-4 md:my-5 mt-2">
296+
{/* Input */}
297+
<div className="w-full lg:w-1/2 mb-4 lg:mb-0">
298+
<h3 className="text-lg font-medium mb-2">SVG Input</h3>
299+
<div className="relative">
300+
<input
301+
ref={fileInputRef}
302+
type="file"
303+
accept=".svg,image/svg+xml"
304+
className="hidden"
305+
onChange={handleFileChange}
306+
/>
307+
<textarea
308+
className={`${DevelopmentToolsStyles.scrollbar} w-full min-h-[300px] bg-black !border !border-[#222222] p-5 pr-14 rounded-xl`}
309+
placeholder="Paste your SVG code here or upload an SVG file..."
310+
value={svgInput}
311+
onChange={(e) => setSvgInput(e.target.value)}
312+
></textarea>
313+
{svgInput && (
314+
<button
315+
type="button"
316+
onClick={handleClear}
317+
title="Clear"
318+
className="absolute right-3 top-3 h-8 w-8 flex items-center justify-center rounded-md bg-white/10 hover:bg-white/20 border border-white/10 transition disabled:opacity-60 disabled:cursor-not-allowed"
319+
>
320+
<svg
321+
xmlns="http://www.w3.org/2000/svg"
322+
viewBox="0 0 24 24"
323+
fill="currentColor"
324+
className="h-5 w-5 text-white"
325+
>
326+
<path
327+
fillRule="evenodd"
328+
d="M6.225 4.811a1 1 0 011.414 0L12 9.172l4.361-4.361a1 1 0 111.414 1.414L13.414 10.586l4.361 4.361a1 1 0 01-1.414 1.414L12 12l-4.361 4.361a1 1 0 01-1.414-1.414l4.361-4.361-4.361-4.361a1 1 0 010-1.414z"
329+
clipRule="evenodd"
330+
/>
331+
</svg>
332+
</button>
333+
)}
334+
<button
335+
type="button"
336+
onClick={handlePickFile}
337+
title="Choose file"
338+
className="absolute right-12 top-3 h-8 w-8 flex items-center justify-center rounded-md bg-white/10 hover:bg-white/20 border border-white/10 transition"
339+
>
340+
<svg
341+
xmlns="http://www.w3.org/2000/svg"
342+
viewBox="0 0 24 24"
343+
fill="currentColor"
344+
className="h-5 w-5 text-white"
345+
>
346+
<path d="M4 12a6 6 0 016-6h5a3 3 0 110 6H9a1 1 0 100 2h6a5 5 0 100-10H10a8 8 0 100 16h7a1 1 0 100-2h-7a6 6 0 01-6-6z" />
347+
</svg>
348+
</button>
349+
</div>
350+
{error && (
351+
<div className="w-full text-red-400 text-sm mt-2">{error}</div>
352+
)}
353+
</div>
354+
355+
{/* Output */}
356+
<div className="w-full lg:w-1/2 mt-2 lg:mt-0">
357+
<h3 className="text-lg font-medium mb-2">Optimized Output</h3>
358+
<div className="relative">
359+
<textarea
360+
className={`${DevelopmentToolsStyles.scrollbar} w-full min-h-[300px] bg-black !border !border-[#222222] p-5 pr-14 rounded-xl font-mono text-sm`}
361+
value={output}
362+
readOnly
363+
placeholder="Your optimized SVG will appear here..."
364+
></textarea>
365+
{output && (
366+
<button
367+
type="button"
368+
onClick={handleCopy}
369+
title="Copy"
370+
className="absolute right-3 top-3 h-8 w-8 flex items-center justify-center rounded-md bg-white/10 hover:bg-white/20 border border-white/10 transition disabled:opacity-60 disabled:cursor-not-allowed"
371+
>
372+
<svg
373+
xmlns="http://www.w3.org/2000/svg"
374+
viewBox="0 0 24 24"
375+
fill="currentColor"
376+
className="h-5 w-5 text-white"
377+
>
378+
<path d="M16 1a3 3 0 013 3v9a3 3 0 01-3 3H8a3 3 0 01-3-3V4a3 3 0 013-3h8zm-8 2a1 1 0 00-1 1v9a1 1 0 001 1h8a1 1 0 001-1V4a1 1 0 00-1-1H8z" />
379+
<path d="M6 18a2 2 0 002 2h8a2 2 0 002-2v-1a1 1 0 112 0v1a4 4 0 01-4 4H8a4 4 0 01-4-4v-1a1 1 0 112 0v1z" />
380+
</svg>
381+
</button>
382+
)}
383+
</div>
384+
</div>
385+
</div>
386+
</div>
387+
</div>
388+
</div>
389+
</div>
390+
</section>
391+
);
392+
};
393+
394+
export default SvgConverter;

0 commit comments

Comments
 (0)