diff --git a/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx b/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx new file mode 100644 index 00000000..88aa48bd --- /dev/null +++ b/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx @@ -0,0 +1,204 @@ +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 ( + + ); +}; + +