UNPKG

react-inlinesvg

Version:
551 lines (544 loc) 15.5 kB
"use client"; var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/index.tsx import React, { cloneElement, isValidElement, useCallback, useEffect as useEffect2, useReducer, useRef as useRef2, useState } from "react"; import convert2 from "react-from-dom"; // src/config.ts var CACHE_NAME = "react-inlinesvg"; var CACHE_MAX_RETRIES = 10; var STATUS = { IDLE: "idle", LOADING: "loading", LOADED: "loaded", FAILED: "failed", READY: "ready", UNSUPPORTED: "unsupported" }; // src/modules/helpers.ts function randomCharacter(character) { return character[Math.floor(Math.random() * character.length)]; } function canUseDOM() { return !!(typeof window !== "undefined" && window.document?.createElement); } function isSupportedEnvironment() { return supportsInlineSVG() && typeof window !== "undefined" && window !== null; } function omit(input, ...filter) { const output = {}; for (const key in input) { if ({}.hasOwnProperty.call(input, key)) { if (!filter.includes(key)) { output[key] = input[key]; } } } return output; } function randomString(length) { const letters = "abcdefghijklmnopqrstuvwxyz"; const numbers = "1234567890"; const charset = `${letters}${letters.toUpperCase()}${numbers}`; let R = ""; for (let index = 0; index < length; index++) { R += randomCharacter(charset); } return R; } async function request(url, options) { const response = await fetch(url, options); const contentType = response.headers.get("content-type"); const [fileType] = (contentType ?? "").split(/ ?; ?/); if (response.status > 299) { throw new Error("Not found"); } if (!["image/svg+xml", "text/plain"].some((d) => fileType.includes(d))) { throw new Error(`Content type isn't valid: ${fileType}`); } return response.text(); } function sleep(seconds = 1) { return new Promise((resolve) => { setTimeout(resolve, seconds * 1e3); }); } function supportsInlineSVG() { if (!document) { return false; } const div = document.createElement("div"); div.innerHTML = "<svg />"; const svg = div.firstChild; return !!svg && svg.namespaceURI === "http://www.w3.org/2000/svg"; } // src/modules/cache.ts var CacheStore = class { constructor() { __publicField(this, "cacheApi"); __publicField(this, "cacheStore"); __publicField(this, "subscribers", []); __publicField(this, "isReady", false); this.cacheStore = /* @__PURE__ */ new Map(); let cacheName = CACHE_NAME; let usePersistentCache = false; if (canUseDOM()) { cacheName = window.REACT_INLINESVG_CACHE_NAME ?? CACHE_NAME; usePersistentCache = !!window.REACT_INLINESVG_PERSISTENT_CACHE && "caches" in window; } if (usePersistentCache) { caches.open(cacheName).then((cache) => { this.cacheApi = cache; }).catch((error) => { console.error(`Failed to open cache: ${error.message}`); this.cacheApi = void 0; }).finally(() => { this.isReady = true; const callbacks = [...this.subscribers]; this.subscribers.length = 0; callbacks.forEach((callback) => { try { callback(); } catch (error) { console.error(`Error in CacheStore subscriber callback: ${error.message}`); } }); }); } else { this.isReady = true; } } onReady(callback) { if (this.isReady) { callback(); } else { this.subscribers.push(callback); } } async get(url, fetchOptions) { await (this.cacheApi ? this.fetchAndAddToPersistentCache(url, fetchOptions) : this.fetchAndAddToInternalCache(url, fetchOptions)); return this.cacheStore.get(url)?.content ?? ""; } set(url, data) { this.cacheStore.set(url, data); } isCached(url) { return this.cacheStore.get(url)?.status === STATUS.LOADED; } async fetchAndAddToInternalCache(url, fetchOptions) { const cache = this.cacheStore.get(url); if (cache?.status === STATUS.LOADING) { await this.handleLoading(url, async () => { this.cacheStore.set(url, { content: "", status: STATUS.IDLE }); await this.fetchAndAddToInternalCache(url, fetchOptions); }); return; } if (!cache?.content) { this.cacheStore.set(url, { content: "", status: STATUS.LOADING }); try { const content = await request(url, fetchOptions); this.cacheStore.set(url, { content, status: STATUS.LOADED }); } catch (error) { this.cacheStore.set(url, { content: "", status: STATUS.FAILED }); throw error; } } } async fetchAndAddToPersistentCache(url, fetchOptions) { const cache = this.cacheStore.get(url); if (cache?.status === STATUS.LOADED) { return; } if (cache?.status === STATUS.LOADING) { await this.handleLoading(url, async () => { this.cacheStore.set(url, { content: "", status: STATUS.IDLE }); await this.fetchAndAddToPersistentCache(url, fetchOptions); }); return; } this.cacheStore.set(url, { content: "", status: STATUS.LOADING }); const data = await this.cacheApi?.match(url); if (data) { const content = await data.text(); this.cacheStore.set(url, { content, status: STATUS.LOADED }); return; } try { await this.cacheApi?.add(new Request(url, fetchOptions)); const response = await this.cacheApi?.match(url); const content = await response?.text() ?? ""; this.cacheStore.set(url, { content, status: STATUS.LOADED }); } catch (error) { this.cacheStore.set(url, { content: "", status: STATUS.FAILED }); throw error; } } async handleLoading(url, callback) { for (let retryCount = 0; retryCount < CACHE_MAX_RETRIES; retryCount++) { if (this.cacheStore.get(url)?.status !== STATUS.LOADING) { return; } await sleep(0.1); } await callback(); } keys() { return [...this.cacheStore.keys()]; } data() { return [...this.cacheStore.entries()].map(([key, value]) => ({ [key]: value })); } async delete(url) { if (this.cacheApi) { await this.cacheApi.delete(url); } this.cacheStore.delete(url); } async clear() { if (this.cacheApi) { const keys = await this.cacheApi.keys(); await Promise.allSettled(keys.map((key) => this.cacheApi.delete(key))); } this.cacheStore.clear(); } }; // src/modules/hooks.tsx import { useEffect, useRef } from "react"; function usePrevious(state) { const ref = useRef(void 0); useEffect(() => { ref.current = state; }); return ref.current; } // src/modules/utils.ts import convert from "react-from-dom"; function getNode(options) { const { baseURL, content, description, handleError, hash, preProcessor, title, uniquifyIDs = false } = options; try { const svgText = processSVG(content, preProcessor); const node = convert(svgText, { nodeOnly: true }); if (!node || !(node instanceof SVGSVGElement)) { throw new Error("Could not convert the src to a DOM Node"); } const svg = updateSVGAttributes(node, { baseURL, hash, uniquifyIDs }); if (description) { const originalDesc = svg.querySelector("desc"); if (originalDesc?.parentNode) { originalDesc.parentNode.removeChild(originalDesc); } const descElement = document.createElementNS("http://www.w3.org/2000/svg", "desc"); descElement.innerHTML = description; svg.prepend(descElement); } if (typeof title !== "undefined") { const originalTitle = svg.querySelector("title"); if (originalTitle?.parentNode) { originalTitle.parentNode.removeChild(originalTitle); } if (title) { const titleElement = document.createElementNS("http://www.w3.org/2000/svg", "title"); titleElement.innerHTML = title; svg.prepend(titleElement); } } return svg; } catch (error) { return handleError(error); } } function processSVG(content, preProcessor) { if (preProcessor) { return preProcessor(content); } return content; } function updateSVGAttributes(node, options) { const { baseURL = "", hash, uniquifyIDs } = options; const replaceableAttributes = ["id", "href", "xlink:href", "xlink:role", "xlink:arcrole"]; const linkAttributes = ["href", "xlink:href"]; const isDataValue = (name, value) => linkAttributes.includes(name) && (value ? !value.includes("#") : false); if (!uniquifyIDs) { return node; } [...node.children].forEach((d) => { if (d.attributes?.length) { const attributes = Object.values(d.attributes).map((a) => { const attribute = a; const match = /url\((.*?)\)/.exec(a.value); if (match?.[1]) { attribute.value = a.value.replace(match[0], `url(${baseURL}${match[1]}__${hash})`); } return attribute; }); replaceableAttributes.forEach((r) => { const attribute = attributes.find((a) => a.name === r); if (attribute && !isDataValue(r, attribute.value)) { attribute.value = `${attribute.value}__${hash}`; } }); } if (d.children.length) { return updateSVGAttributes(d, options); } return d; }); return node; } // src/index.tsx var cacheStore; function ReactInlineSVG(props) { const { cacheRequests = true, children = null, description, fetchOptions, innerRef, loader = null, onError, onLoad, src, title, uniqueHash } = props; const [state, setState] = useReducer( (previousState2, nextState) => ({ ...previousState2, ...nextState }), { content: "", element: null, isCached: cacheRequests && cacheStore.isCached(props.src), status: STATUS.IDLE } ); const { content, element, isCached, status } = state; const previousProps = usePrevious(props); const previousState = usePrevious(state); const hash = useRef2(uniqueHash ?? randomString(8)); const isActive = useRef2(false); const isInitialized = useRef2(false); const handleError = useCallback( (error) => { if (isActive.current) { setState({ status: error.message === "Browser does not support SVG" ? STATUS.UNSUPPORTED : STATUS.FAILED }); onError?.(error); } }, [onError] ); const handleLoad = useCallback((loadedContent, hasCache = false) => { if (isActive.current) { setState({ content: loadedContent, isCached: hasCache, status: STATUS.LOADED }); } }, []); const fetchContent = useCallback(async () => { const responseContent = await request(src, fetchOptions); handleLoad(responseContent); }, [fetchOptions, handleLoad, src]); const getElement = useCallback(() => { try { const node = getNode({ ...props, handleError, hash: hash.current, content }); const convertedElement = convert2(node); if (!convertedElement || !isValidElement(convertedElement)) { throw new Error("Could not convert the src to a React element"); } setState({ element: convertedElement, status: STATUS.READY }); } catch (error) { handleError(error); } }, [content, handleError, props]); const getContent = useCallback(async () => { const dataURI = /^data:image\/svg[^,]*?(;base64)?,(.*)/u.exec(src); let inlineSrc; if (dataURI) { inlineSrc = dataURI[1] ? window.atob(dataURI[2]) : decodeURIComponent(dataURI[2]); } else if (src.includes("<svg")) { inlineSrc = src; } if (inlineSrc) { handleLoad(inlineSrc); return; } try { if (cacheRequests) { const cachedContent = await cacheStore.get(src, fetchOptions); handleLoad(cachedContent, true); } else { await fetchContent(); } } catch (error) { handleError(error); } }, [cacheRequests, fetchContent, fetchOptions, handleError, handleLoad, src]); const load = useCallback(async () => { if (isActive.current) { setState({ content: "", element: null, isCached: false, status: STATUS.LOADING }); } }, []); useEffect2( () => { isActive.current = true; if (!canUseDOM() || isInitialized.current) { return void 0; } try { if (status === STATUS.IDLE) { if (!isSupportedEnvironment()) { throw new Error("Browser does not support SVG"); } if (!src) { throw new Error("Missing src"); } load(); } } catch (error) { handleError(error); } isInitialized.current = true; return () => { isActive.current = false; }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); useEffect2(() => { if (!canUseDOM() || !previousProps) { return; } if (previousProps.src !== src) { if (!src) { handleError(new Error("Missing src")); return; } load(); } }, [handleError, load, previousProps, src]); useEffect2(() => { if (status === STATUS.LOADED) { getElement(); } }, [status, getElement]); useEffect2(() => { if (!canUseDOM() || !previousProps || previousProps.src !== src) { return; } if (previousProps.title !== title || previousProps.description !== description) { getElement(); } }, [description, getElement, previousProps, src, title]); useEffect2(() => { if (!previousState) { return; } switch (status) { case STATUS.LOADING: { if (previousState.status !== STATUS.LOADING) { getContent(); } break; } case STATUS.LOADED: { if (previousState.status !== STATUS.LOADED) { getElement(); } break; } case STATUS.READY: { if (previousState.status !== STATUS.READY) { onLoad?.(src, isCached); } break; } } }, [getContent, getElement, isCached, onLoad, previousState, src, status]); const elementProps = omit( props, "baseURL", "cacheRequests", "children", "description", "fetchOptions", "innerRef", "loader", "onError", "onLoad", "preProcessor", "src", "title", "uniqueHash", "uniquifyIDs" ); if (!canUseDOM()) { return loader; } if (element) { return cloneElement(element, { ref: innerRef, ...elementProps }); } if ([STATUS.UNSUPPORTED, STATUS.FAILED].includes(status)) { return children; } return loader; } function InlineSVG(props) { if (!cacheStore) { cacheStore = new CacheStore(); } const { loader } = props; const [isReady, setReady] = useState(cacheStore.isReady); useEffect2(() => { if (isReady) { return; } cacheStore.onReady(() => { setReady(true); }); }, [isReady]); if (!isReady) { return loader; } return /* @__PURE__ */ React.createElement(ReactInlineSVG, { ...props }); } export { cacheStore, InlineSVG as default }; //# sourceMappingURL=index.mjs.map