diff --git a/src/App.jsx b/src/App.jsx index 6dfe075..2d50478 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -69,6 +69,7 @@ import SqlFormatter from "./pages/DevUtilities/devutilities/SqlFormatter"; import SqlSchemaConverter from "./pages/DevUtilities/devutilities/SqlSchemaConverter"; import SubnetCalculator from "./pages/DevUtilities/devutilities/SubnetCalculator"; import SvgOptimizer from "./pages/DevUtilities/devutilities/SvgOptimizer"; +import SvgSpritesheetMerger from "./pages/DevUtilities/devutilities/SvgSpritesheetMerger"; import SlugGenerator from "./pages/DevUtilities/devutilities/SlugGenerator"; import TextCaseConverter from "./pages/DevUtilities/devutilities/TextCaseConverter"; import TimestampConverter from "./pages/DevUtilities/devutilities/TimestampConverter"; @@ -545,6 +546,10 @@ function AppInner({ toggleHUD, hudVisible }) { path="/devutilities/svg-to-data-uri" element={} /> + } + /> } diff --git a/src/config/sidebarSections.js b/src/config/sidebarSections.js index 43df62e..60baa8d 100644 --- a/src/config/sidebarSections.js +++ b/src/config/sidebarSections.js @@ -342,6 +342,12 @@ const SIDEBAR_SECTIONS = [ description: "Optimize SVGs, generate React JSX components, and compile Data URIs", path: "/devutilities/svg-optimizer", }, + { + label: "SVG Spritesheet Merger", + description: + "Merge multiple SVGs into a single optimized -based spritesheet.", + path: "/devutilities/svg-spritesheet-merger", + }, { label: "Password Generator", description: diff --git a/src/pages/DevUtilities/DevUtilities.jsx b/src/pages/DevUtilities/DevUtilities.jsx index 580ec6c..29b52cd 100644 --- a/src/pages/DevUtilities/DevUtilities.jsx +++ b/src/pages/DevUtilities/DevUtilities.jsx @@ -115,6 +115,28 @@ const DevUtilities = () => { ), }, + { + title: "SVG Spritesheet Merger", + description: + "Merge multiple standalone SVGs into a single optimized -based spritesheet.", + keywords: "svg sprite spritesheet symbol icon merge bundle", + path: "/devutilities/svg-spritesheet-merger", + icon: ( + + + + ), + }, { title: "SVG to Data URI Converter", description: diff --git a/src/pages/DevUtilities/devutilities/SvgSpritesheetMerger.jsx b/src/pages/DevUtilities/devutilities/SvgSpritesheetMerger.jsx new file mode 100644 index 0000000..d489390 --- /dev/null +++ b/src/pages/DevUtilities/devutilities/SvgSpritesheetMerger.jsx @@ -0,0 +1,467 @@ +import { useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { + FaArrowLeft, + FaCopy, + FaDownload, + FaFileCode, + FaFileUpload, + FaPlus, + FaTrash, +} from "react-icons/fa"; +import { useTheme } from "../../../context/ThemeContext"; + +let uidCounter = 0; +const nextUid = () => `sprite-item-${(uidCounter += 1)}`; + +// Turns a filename or arbitrary label into a safe, kebab-case symbol id. +function slugify(str) { + const slug = str + .toLowerCase() + .replace(/\.[^/.]+$/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "icon"; +} + +function ensureUniqueId(base, existingIds) { + if (!existingIds.has(base)) return base; + let i = 2; + while (existingIds.has(`${base}-${i}`)) i += 1; + return `${base}-${i}`; +} + +// Parses raw SVG markup, extracting a viewBox and the inner markup so it +// can be re-wrapped inside a . +function parseSvgMarkup(svgText) { + try { + const doc = new DOMParser().parseFromString(svgText, "image/svg+xml"); + const svgEl = doc.documentElement; + if ( + !svgEl || + svgEl.nodeName.toLowerCase() !== "svg" || + doc.querySelector("parsererror") + ) { + return null; + } + let viewBox = svgEl.getAttribute("viewBox"); + if (!viewBox) { + const w = parseFloat(svgEl.getAttribute("width")) || 24; + const h = parseFloat(svgEl.getAttribute("height")) || 24; + viewBox = `0 0 ${w} ${h}`; + } + const inner = svgEl.innerHTML.trim(); + if (!inner) return null; + return { viewBox, inner, raw: svgText.trim() }; + } catch { + return null; + } +} + +function buildSpritesheet(icons) { + const symbols = icons + .map( + (icon) => + ` \n ${icon.inner}\n ` + ) + .join("\n"); + return `\n${symbols}\n`; +} + +export default function SvgSpritesheetMerger() { + const { dark } = useTheme(); + const fileInputRef = useRef(null); + const [icons, setIcons] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [pasteValue, setPasteValue] = useState(""); + const [hoveredId, setHoveredId] = useState(null); + + const theme = { + light: { + wrapper: "bg-[#F8F9FA] text-zinc-900", + heading: "text-zinc-900", + subtext: "text-zinc-500", + card: "bg-white border-zinc-200/85 shadow-sm", + input: + "bg-zinc-50 border-zinc-200 text-zinc-900 placeholder-zinc-400 focus:border-zinc-400 focus:outline-none", + buttonPrimary: + "bg-zinc-900 text-white hover:bg-zinc-800 transition-colors shadow-sm", + buttonSecondary: + "bg-white text-zinc-700 border-zinc-200 hover:bg-zinc-50 hover:text-zinc-900 transition-colors", + backLink: + "bg-white border-neutral-200 text-neutral-600 hover:text-black hover:border-neutral-350", + row: "bg-zinc-50/60 border-zinc-150", + codeBox: "bg-zinc-900 text-zinc-100 border-zinc-800", + dropZone: "border-zinc-200 bg-zinc-50/40", + gridCell: "bg-zinc-50/60 border-zinc-150 hover:border-zinc-300", + }, + dark: { + wrapper: "bg-[#090A0F] text-zinc-100", + heading: "text-zinc-100", + subtext: "text-zinc-400", + card: "bg-zinc-900/50 border-zinc-800/85 backdrop-blur-md shadow-lg", + input: + "bg-zinc-950/70 border-zinc-800 text-zinc-100 placeholder-zinc-600 focus:border-zinc-600 focus:outline-none", + buttonPrimary: + "bg-white text-zinc-950 hover:bg-zinc-200 transition-colors shadow-sm", + buttonSecondary: + "bg-zinc-900 text-zinc-300 border-zinc-800 hover:bg-zinc-800 hover:text-white transition-colors", + backLink: + "bg-zinc-800/80 border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-600", + row: "bg-zinc-950/50 border-zinc-800", + codeBox: "bg-black/40 text-emerald-400 border-zinc-800/80 font-mono", + dropZone: "border-zinc-800 bg-zinc-950/30", + gridCell: "bg-zinc-950/50 border-zinc-800 hover:border-zinc-600", + }, + }; + const t = dark ? theme.dark : theme.light; + + const addIcon = (label, svgText) => { + const parsed = parseSvgMarkup(svgText); + if (!parsed) { + toast.error(`Couldn't parse "${label}" as valid SVG`); + return; + } + setIcons((prev) => { + const existingIds = new Set(prev.map((i) => i.symbolId)); + const symbolId = ensureUniqueId(slugify(label), existingIds); + return [ + ...prev, + { + uid: nextUid(), + name: label, + symbolId, + viewBox: parsed.viewBox, + inner: parsed.inner, + raw: parsed.raw, + }, + ]; + }); + }; + + const handleFiles = (files) => { + const svgFiles = Array.from(files).filter( + (f) => f.type === "image/svg+xml" || f.name.toLowerCase().endsWith(".svg") + ); + if (svgFiles.length === 0) { + toast.error("Please choose valid .svg files"); + return; + } + svgFiles.forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => addIcon(file.name, e.target.result); + reader.readAsText(file); + }); + toast.success(`Loading ${svgFiles.length} SVG file(s)...`); + }; + + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + const handleDragLeave = () => setIsDragging(false); + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files?.length) handleFiles(e.dataTransfer.files); + }; + + const handleAddFromPaste = () => { + if (!pasteValue.trim()) { + toast.error("Paste some SVG markup first"); + return; + } + addIcon(`icon-${icons.length + 1}`, pasteValue); + setPasteValue(""); + }; + + const renameIcon = (uid, newLabel) => { + setIcons((prev) => { + const existingIds = new Set( + prev.filter((i) => i.uid !== uid).map((i) => i.symbolId) + ); + return prev.map((icon) => + icon.uid === uid + ? { ...icon, symbolId: ensureUniqueId(slugify(newLabel) || "icon", existingIds) } + : icon + ); + }); + }; + + const removeIcon = (uid) => { + setIcons((prev) => prev.filter((icon) => icon.uid !== uid)); + }; + + const clearAll = () => { + setIcons([]); + toast.success("Cleared all icons"); + }; + + const spritesheetXml = useMemo(() => buildSpritesheet(icons), [icons]); + + const copySpritesheet = async () => { + if (icons.length === 0) return; + try { + await navigator.clipboard.writeText(spritesheetXml); + toast.success("Spritesheet copied to clipboard!"); + } catch { + toast.error("Failed to copy"); + } + }; + + const copyUsage = async (symbolId) => { + const snippet = `\n \n`; + try { + await navigator.clipboard.writeText(snippet); + toast.success(`Copied usage for #${symbolId}`); + } catch { + toast.error("Failed to copy"); + } + }; + + const downloadSpritesheet = () => { + if (icons.length === 0) return; + const blob = new Blob([spritesheetXml], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "spritesheet.svg"; + a.click(); + URL.revokeObjectURL(url); + toast.success("Downloaded spritesheet.svg"); + }; + + const usageSnippet = hoveredId + ? `\n \n` + : `\n \n`; + + return ( +
+ SVG Spritesheet Merger — DevTasks + + + {/* Hidden compiled spritesheet so references resolve in the preview grid */} + {icons.length > 0 && ( +