import { removeChildNodes } from "@web3r/flowerkit/dom"; import { onDOMReady } from "@web3r/flowerkit/evt"; import type { TGetFromServerArgs } from "@web3r/flowerkit/net"; import { getFromServer } from "@web3r/flowerkit/net"; import { getDocument } from "ssr-window"; import { getSanitizedHTML } from "../../utils/getSanitizedHTML"; type TGetSpriteUrlParams = { fileName?: string; svgSpriteRevision?: number; revision?: number; filePath?: string; env?: string; }; /** * Singleton. Плагин отвечает за загрузку SVG-спрайта и встраивание иконок на страницу. Принимает в себя объект с параметрами. * В случае если SVG спрайт успешно загрузился на клиенте, то плагин помещает содержимое файла в локальное хранилище с ключом, указанным в `storageInlineSVGDataName`. Временная метка `storageInlineSVGDataName` служит для валидации кеша и должна задаваться с backend как один из ключей глобальных параметров приложения — `svgSpriteRevision` в формате числа, конвертируемого в дату. * Предупреждение `[IconSprite] Sprite was found in cache without stamp. Please, provide prop 'svgSpriteRevision' to plugins config` возникает, когда глобальная временная метка не была присвоена, но содержимое спрайта было добавлено в кеш. По факту это делает кеш иконок бесконечным. * @module IconSprite * @param cfg{Object=} = объект конфигурации * @param cfg.filePath{String=} путь к файлу спрайта (до папки /build/) * @param cfg.fileName{String=} название файла со спрайтом (по умолчанию icons.svg) * @param cfg.requestCfg{Object=} параметры запроса к спрайту * @param cfg.svgSpriteRevision{Number=} временная метка даты последнего изменения файла * @param cfg.getSpriteUrl{Function=} функция получения полного адреса спрайта * @param cfg.idSprite{String=} уникальный ID генерируемого блока со спрайтом * @param cfg.env{String=} название переменной текущего окружения (process.env.NODE_ENV) * @param cfg.storageInlineSVGDataName{String=} название ключа в localStorage для содержимого спрайта * @param cfg.storageInlineSVGRevisionName{String=} название ключа в localStorage для временной метки спрайта * */ export class IconSprite { static #instance: IconSprite | null = null; static #iconExcludeAttr = "data-js-IconSpriteExclude"; static #iconIdAttr = "data-js-IconSpriteSvgId"; filePath: string; fileName: string; requestCfg: Record; timeStamp: number; svgSpriteRevision: number; getSpriteUrl: (params: TGetSpriteUrlParams) => string; env: string; idSprite: string; data: string | null; storageInlineSVGDataName: string; storageInlineSVGRevisionName: string; isCached: boolean; /** * Стандартная конфигурация плагина иконок. * Используется как базовый набор параметров при инициализации. * @type {{ * filePath: string, * fileName: string, * idSprite: string, * svgSpriteRevision: number, * env: string | undefined, * storageInlineSVGDataName: string, * storageInlineSVGRevisionName: string, * getSpriteUrl: (params: {filePath?: string, env?: string, fileName?: string, svgSpriteRevision?: number, revision?: number}) => string, * requestCfg: {type: string, mode: string, isBubble: boolean} * }} */ static defaultCfg = { filePath: "/build/", fileName: "icons.svg", idSprite: "svgSprite", svgSpriteRevision: 0, env: process.env.NODE_ENV, storageInlineSVGDataName: "inlineSVGData", storageInlineSVGRevisionName: "inlineSVGRev", /** * Получает ссылку на спрайт * @param filePath{String=} путь к спрайту * @param fileName{String=} название файла спрайта * @param svgSpriteRevision{Number=} дата последнего обновления спрайта * @param revision{Number=} устаревший алиас даты последнего обновления спрайта * @param env{String=} название переменной текущего окружения (process.env.NODE_ENV) * @returns {string} */ getSpriteUrl: ({ filePath = "", env = process.env.NODE_ENV, fileName = "icons.svg", svgSpriteRevision, revision, }: TGetSpriteUrlParams) => { let resolvedRevision = new Date().getTime(); if (typeof revision === "number") { resolvedRevision = revision; } if (typeof svgSpriteRevision === "number") { resolvedRevision = svgSpriteRevision; } return env === "development" ? `./${fileName}` : `${filePath}/${fileName}?revision=${resolvedRevision.toString()}`; }, requestCfg: { type: "text", mode: "same-origin", isBubble: false, }, }; constructor(cfg: Partial<{ filePath: string; fileName: string; requestCfg: TGetFromServerArgs; svgSpriteRevision: number; getSpriteUrl: (params: TGetSpriteUrlParams) => string; idSprite: string; env: string; storageInlineSVGDataName: string; storageInlineSVGRevisionName: string; }> = {}) { if (IconSprite.#instance) { return IconSprite.#instance; } const { filePath, fileName, requestCfg, svgSpriteRevision, getSpriteUrl, idSprite, env, storageInlineSVGDataName, storageInlineSVGRevisionName, } = { ...IconSprite.defaultCfg, ...cfg }; this.filePath = filePath; this.fileName = fileName; this.requestCfg = requestCfg; this.timeStamp = new Date().getTime(); this.svgSpriteRevision = svgSpriteRevision || this.timeStamp; this.getSpriteUrl = getSpriteUrl.bind(this); this.env = env || ""; this.idSprite = idSprite; this.data = null; this.storageInlineSVGDataName = storageInlineSVGDataName; this.storageInlineSVGRevisionName = storageInlineSVGRevisionName; this.isCached = typeof localStorage !== "undefined" && !!localStorage.getItem(storageInlineSVGDataName) && !this.isExpired(); this.init(); IconSprite.#instance = this; } /** * Создание спрайта * @private */ #createSprite() { onDOMReady(() => { const currentNode = getDocument().getElementById(this.idSprite); if (currentNode) { console.error(`[IconSprite] Element with id '${this.idSprite}' is already exist: `, currentNode); } else { const sprite = getDocument().createElement("div"); const html = getSanitizedHTML(this.data || ""); sprite.id = this.idSprite; sprite.style.cssText = `position: absolute !important; clip: rect(1px, 1px, 1px, 1px); padding: 0 !important; border: 0 !important; height: 1px !important; width: 1px !important; overflow: hidden; left: -1px;`; sprite.insertAdjacentHTML("afterbegin", html); getDocument().body.appendChild(sprite); } this.data = null; }); } /** * Callback-функция загрузки спрайта * @param sprite{String} содержимое файла спрайта */ #onSpriteLoaded(sprite = "") { this.data = sprite; if (typeof localStorage !== "undefined") { localStorage.setItem(this.storageInlineSVGDataName, this.data.toString()); localStorage.setItem(this.storageInlineSVGRevisionName, this.svgSpriteRevision.toString()); } this.#createSprite(); } /** * Получение спрайта из кеша */ #getFromCache() { this.data = (typeof localStorage !== "undefined" && localStorage.getItem(this.storageInlineSVGDataName)) || null; this.#createSprite(); } /** * Загружает спрайт с указанного URL * @param url{String} адрес спрайта * @returns {Promise} промис с содержимым спрайта */ async getSprite(url: string) { return await getFromServer({ url, ...this.requestCfg, }); } /** * Проверяет, устарел ли спрайт в кеше * @returns {Boolean} true, если спрайт устарел или отсутствует */ isExpired() { if (typeof localStorage === "undefined") { return true; } else { const cachedData = localStorage.getItem(this.storageInlineSVGRevisionName); switch (true) { case this.svgSpriteRevision === this.timeStamp: { if (this.env === "production") { console.error(`[IconSprite] Sprite was found in cache without stamp. Please, provide prop 'svgSpriteRevision' to plugin's config for proper caching!"`); } return true; } case !!cachedData: { const serverStamp = new Date(this.svgSpriteRevision); const clientStamp = new Date(Number(cachedData)); const isExpired = serverStamp > clientStamp; const details = `(server stamp: ${serverStamp}, client stamp: ${clientStamp})`; console.debug(`[IconSprite] Sprite was found in cache with ${isExpired ? "expired" : "actual"} stamp ${details}`); return isExpired; } default: { return true; } } } } /** * Проверяет, валидная ли иконка * @param src{String} адрес иконки * @param svg{SVGElement} иконка * @param use{SVGUseElement} элемент use svg * @returns {boolean} */ static #isIconValid(src: string, svg: SVGElement, use: SVGUseElement) { if (svg.hasAttribute(IconSprite.#iconExcludeAttr)) { removeChildNodes(svg); svg.appendChild(use); return false; } else { const originID = src.replace("#", ""); svg.setAttribute(IconSprite.#iconIdAttr, originID); svg.classList.add(originID); return true; } } /** * Инициализирует плагин, загружая спрайт из кеша или с сервера */ init() { if (this.isCached) { this.#getFromCache(); } else { const url = this.getSpriteUrl({ fileName: this.fileName, svgSpriteRevision: this.svgSpriteRevision, filePath: this.filePath, env: this.env, }); this.getSprite(url).then((sprite) => { if (sprite && sprite?.startsWith(" console.error(`[IconSprite] Sprite loading failed: ${status}`)); } } }