import React from "react"; export type RemoteSvgOptions = { strokeColor?: string; fillColor?: string; className?: string; title?: string; color?: string; }; function transformSvg(svgText: string, options: RemoteSvgOptions): string { try { const parser = new DOMParser(); const doc = parser.parseFromString(svgText, "image/svg+xml"); const svgEl = doc.querySelector("svg"); if (!svgEl) return svgText; svgEl.style.outline = "none"; svgEl.style.border = "none"; svgEl.style.margin = "0"; svgEl.style.padding = "0"; svgEl.style.display = "inline-block"; svgEl.style.verticalAlign = "middle"; svgEl.style.overflow = "visible"; svgEl.style.position = "relative"; // Set only provided attributes to avoid clobbering inner shapes if (options.className) svgEl.setAttribute("class", options.className); if (options.strokeColor) svgEl.setAttribute("stroke", options.strokeColor); if (options.fillColor !== undefined) svgEl.setAttribute("fill", options.fillColor); const viewBox = svgEl.getAttribute("viewBox"); let vbX = 0, vbY = 0, vbW = 0, vbH = 0; if (viewBox) { const parts = viewBox.split(/\s+/).map((n) => Number(n)); if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) { vbX = parts[0]; vbY = parts[1]; vbW = parts[2]; vbH = parts[3]; } } // Only consider direct child rects; safer heuristic const rects = Array.from(svgEl.querySelectorAll("rect")).filter((r) => r.parentNode === svgEl); rects.forEach((r) => { const xAttr = r.getAttribute("x") || "0"; const yAttr = r.getAttribute("y") || "0"; const wAttr = r.getAttribute("width") || ""; const hAttr = r.getAttribute("height") || ""; const fill = r.getAttribute("fill"); const x = Number(xAttr); const y = Number(yAttr); const w = Number(wAttr); const h = Number(hAttr); const isExactHundredPercent = wAttr === "100%" && hAttr === "100%" && (xAttr === "0" || xAttr === "0%") && (yAttr === "0" || yAttr === "0%"); const approximatelyCoversViewBox = ( vbW > 0 && vbH > 0 && !Number.isNaN(w) && !Number.isNaN(h) && Math.abs(w - vbW) <= Math.max(1, vbW * 0.02) && Math.abs(h - vbH) <= Math.max(1, vbH * 0.02) && Math.abs(x - vbX) <= Math.max(1, vbW * 0.02) && Math.abs(y - vbY) <= Math.max(1, vbH * 0.02) ); const noFill = (fill === null || fill === "none" || fill === "transparent"); const looksLikeFrame = noFill && (isExactHundredPercent || approximatelyCoversViewBox); if (looksLikeFrame) { r.parentElement?.removeChild(r); } }); return svgEl.outerHTML; } catch { return svgText; } } // Simple in-memory LRU cache for transformed SVG markup const SVG_CACHE_LIMIT = 15; const svgCache: Map = new Map(); function makeCacheKey(url: string, options: RemoteSvgOptions): string { return [ url, `sc=${options.strokeColor || ""}`, `fc=${options.fillColor || ""}`, `cls=${options.className || ""}`, ].join("|"); } function cacheGet(key: string): string | undefined { const value = svgCache.get(key); if (value !== undefined) { // refresh LRU order svgCache.delete(key); svgCache.set(key, value); } return value; } function cacheSet(key: string, value: string) { if (svgCache.has(key)) svgCache.delete(key); svgCache.set(key, value); if (svgCache.size > SVG_CACHE_LIMIT) { const oldestKey = svgCache.keys().next().value as string | undefined; if (oldestKey !== undefined) svgCache.delete(oldestKey); } } export function useRemoteSvgIcon(url?: string, options: RemoteSvgOptions = {}) { const [svgMarkup, setSvgMarkup] = React.useState(null); const [error, setError] = React.useState(null); React.useEffect(() => { let cancelled = false; async function run() { if (!url) { // build simple fallback svg const stroke = options.strokeColor || "currentColor"; const fill = options.fillColor ?? "none"; const cls = options.className ? ` class=\"${options.className}\"` : ""; setSvgMarkup(``); return; } // non-svg extensions fallback if (/\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(url)) { const stroke = options.strokeColor || "currentColor"; const fill = options.fillColor ?? "none"; const cls = options.className ? ` class=\"${options.className}\"` : ""; setSvgMarkup(``); return; } // Cache lookup const cacheKey = makeCacheKey(url, options); const cached = cacheGet(cacheKey); if (cached) { setSvgMarkup(cached); setError(null); return; } try { const res = await fetch(url); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const ct = res.headers.get("content-type") || ""; if (ct && !ct.includes("svg")) { throw new Error(`Non-SVG content: ${ct}`); } const text = await res.text(); if (cancelled) return; const transformed = transformSvg(text, options); cacheSet(cacheKey, transformed); setSvgMarkup(transformed); setError(null); } catch (e: any) { if (cancelled) return; setError(e?.message || "Failed to load SVG"); const stroke = options.strokeColor || "currentColor"; const fill = options.fillColor ?? "none"; const cls = options.className ? ` class=\"${options.className}\"` : ""; setSvgMarkup(``); if (process.env.NODE_ENV !== "production") { // eslint-disable-next-line no-console console.warn("RemoteSvgIcon fetch error", e); } } } run(); return () => { cancelled = true; }; }, [url, options.strokeColor, options.fillColor, options.className]); return { svgMarkup, error }; } export const RemoteSvgIcon: React.FC<{ url?: string; strokeColor?: string; fillColor?: string; className?: string; title?: string; color?: string; }> = ({ url, strokeColor, fillColor, className, title, color }) => { const { svgMarkup } = useRemoteSvgIcon(url, { strokeColor, fillColor, className, title, color }); if (!svgMarkup) return null; return ( ); };