/** * SVG Icon Loader Utility * Loads SVG icons from files and caches them for performance. * * Hot-path icons (editor toolbar, UI chrome, theme toggle) are inlined via * INLINE_ICONS and used to seed the cache at construction, so first paint needs * zero network fetches. Long-tail icons still load lazily from the CDN/dev path. */ import { INLINE_ICONS } from "./icon-data"; interface IconOptions { width?: number; height?: number; className?: string; } class IconLoader { private basePath: string; private cache: Map; private loading: Map>; constructor(basePath?: string) { // Auto-detect base path if not provided if (!basePath) { if (typeof window !== "undefined") { // Check if running in monochrome-edge repo itself (for development) const isMonochromeEdgeDev = (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" || window.location.hostname === "") && (window.location.pathname.includes("/monochrome-edge/") || window.location.pathname === "/" || window.location.pathname.startsWith("/docs/") || window.location.pathname.startsWith("/ui/")); if (isMonochromeEdgeDev) { basePath = "/ui/assets/icons/"; } else { // Use CDN for all other cases (GitHub Pages, production, other projects) basePath = "https://cdn.jsdelivr.net/npm/@monochrome-edge/ui@latest/dist/assets/icons/"; } } else { // SSR fallback - use CDN basePath = "https://cdn.jsdelivr.net/npm/@monochrome-edge/ui@latest/dist/assets/icons/"; } } this.basePath = basePath; this.cache = new Map(); this.loading = new Map(); // Seed the cache with inlined hot-path icons so load()/loadSync() return // synchronously with no network round trip. Long-tail icons fall through to // fetchSvg() on demand. for (const [name, svg] of Object.entries(INLINE_ICONS)) { this.cache.set(name, svg); } } /** * Load an SVG icon * @param {string} name - Icon name (without .svg extension) * @param {Object} options - Icon options * @param {number} options.width - Icon width (default: 16) * @param {number} options.height - Icon height (default: 16) * @param {string} options.className - Additional CSS class * @returns {Promise} SVG string */ async load(name: string, options: IconOptions = {}): Promise { const { width = 16, height = 16, className = "" } = options; // Check cache if (this.cache.has(name)) { return this.formatSvg(this.cache.get(name)!, { width, height, className, }); } // Check if already loading if (this.loading.has(name)) { await this.loading.get(name); return this.formatSvg(this.cache.get(name)!, { width, height, className, }); } // Load SVG const loadPromise = this.fetchSvg(name); this.loading.set(name, loadPromise); try { const svg = await loadPromise; this.cache.set(name, svg); this.loading.delete(name); return this.formatSvg(svg, { width, height, className }); } catch (error) { this.loading.delete(name); console.error(`Failed to load icon: ${name}`, error); return this.getFallbackIcon(width, height); } } /** * Load SVG synchronously (uses cached version or returns placeholder) * @param {string} name - Icon name * @param {Object} options - Icon options * @returns {string} SVG string */ loadSync(name: string, options: IconOptions = {}): string { const { width = 16, height = 16, className = "" } = options; if (this.cache.has(name)) { return this.formatSvg(this.cache.get(name)!, { width, height, className, }); } // Load in background this.load(name, options).catch(() => {}); // Return placeholder return this.getFallbackIcon(width, height); } /** * Fetch SVG from server */ async fetchSvg(name: string): Promise { const url = `${this.basePath}${name}.svg`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.text(); } /** * Format SVG with custom attributes */ formatSvg( svg: string, { width, height, className }: Required, ): string { // Parse SVG const parser = new DOMParser(); const doc = parser.parseFromString(svg, "image/svg+xml"); const svgElement = doc.querySelector("svg"); if (!svgElement) { return this.getFallbackIcon(width, height); } // Set attributes svgElement.setAttribute("width", String(width)); svgElement.setAttribute("height", String(height)); if (className) { svgElement.setAttribute("class", className); } // Serialize back to string return new XMLSerializer().serializeToString(svgElement); } /** * Get fallback icon (box) */ getFallbackIcon(width: number, height: number): string { return ``; } /** * Preload multiple icons * @param {string[]} names - Array of icon names */ async preload(names: string[]): Promise { await Promise.all(names.map((name: string) => this.load(name))); } /** * Clear cache */ clearCache(): void { this.cache.clear(); } /** * Get cache size */ getCacheSize(): number { return this.cache.size; } } // Create singleton instance. The hot-path icons are seeded into the cache from // INLINE_ICONS in the constructor, so there is no startup preload fetch — first // paint renders them with zero network round trips. const iconLoader = new IconLoader(); export { iconLoader, IconLoader };