UNPKG

@left4code/svg-renderer

Version:

Responsive SVG path renderer that recalculates coordinates on container resize, transition, or animation.

212 lines (211 loc) 8.46 kB
/** * Evaluates a string expression containing percentages or calculations. * * @param expr - The string expression to evaluate. * @returns The numeric result of the evaluated expression, or 0 if invalid. */ function evalExpression({ expr, width, height, }) { const replaced = expr .replace(/([\d.]+)%/g, (_, num) => { const val = parseFloat(num); return `(${val} / 100)`; }) .replace(/width/g, width.toString()) .replace(/height/g, height.toString()) .replace(/100/g, "100"); try { return Function(`"use strict"; return (${replaced});`)(); } catch (_a) { return 0; } } /** * Generates SVG path data based on provided Paths definitions and dimensions. * * @param params - Object containing paths array, width, and height. * @returns An array of updated Paths with parsed coordinates. */ function createSvgPaths({ paths, width, height, }) { return paths.map((path) => { return Object.assign(Object.assign({}, path), { path: path.path .map(([cmd, x, y]) => { const parsedX = x.includes("%") || x.match(/[+\-*/]/) ? evalExpression({ expr: x.replace(/%/g, "* width / 100"), width, height, }) : x; const parsedY = y.includes("%") || y.match(/[+\-*/]/) ? evalExpression({ expr: y.replace(/%/g, "* height / 100"), width, height, }) : y; const numX = typeof parsedX === "string" ? parseFloat(parsedX) : parsedX; const numY = typeof parsedY === "string" ? parseFloat(parsedY) : parsedY; return `${cmd} ${parseInt(numX)},${parseInt(numY)}`; }) .join(" ") }); }); } /** * Recursively finds the closest parent element with `position: relative`. * * @param element - The starting HTMLElement. * @returns The found parent HTMLElement or null if not found. */ function findRelativeParent(element) { if (!element || !element.parentElement) return null; const parent = element.parentElement; const animationName = window.getComputedStyle(parent).animationName; const transitionProperty = window.getComputedStyle(parent).transitionProperty; if (animationName !== "none" || (transitionProperty !== "all" && transitionProperty !== "none")) { return parent; } return findRelativeParent(parent); } /** * Creates and appends SVG <path> elements to the provided SVG element based on Paths data. * * @param params - Object containing target SVG element, Paths data, width, and height. */ function createSvgElement({ el, paths, width, height, enableBackdropBlur, enableViewBox, }) { var _a, _b, _c; const prevWidth = el.getAttribute("data-width"); const prevHeight = el.getAttribute("data-height"); if (prevWidth != width.toString() || prevHeight != height.toString()) { el.setAttribute("data-width", width.toString()); el.setAttribute("data-height", height.toString()); // Clear previous paths el.querySelectorAll("path").forEach((path) => path.remove()); // Enable viewbox if (enableViewBox) { el.setAttribute("viewBox", `0 0 ${width} ${height}`); } // Create new paths createSvgPaths({ paths, width, height, }).map((p) => { const pathElement = document.createElementNS("http://www.w3.org/2000/svg", "path"); pathElement.setAttribute("d", p.path); pathElement.style.fill = p.style.fill; pathElement.style.stroke = p.style.stroke; pathElement.style.strokeWidth = p.style.strokeWidth; pathElement.style.vectorEffect = "non-scaling-stroke"; pathElement.style.shapeRendering = "geometricPrecision"; el && el.appendChild(pathElement); }); // Backdrop blur masking if (enableBackdropBlur) { const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(el); const encoded = encodeURIComponent(svgString); const dataUri = `data:image/svg+xml,${encoded}`; let divMask = document.createElement("div"); if (((_a = el.nextElementSibling) === null || _a === void 0 ? void 0 : _a.hasAttribute("data-backdrop")) && el.nextElementSibling instanceof HTMLDivElement) { divMask = el.nextElementSibling; } else { divMask.style.opacity = "0"; } divMask.style.willChange = "backdrop-blur"; divMask.style.transition = "opacity 0.8s ease"; divMask.style.maskImage = `url("${dataUri}")`; divMask.style.maskRepeat = "no-repeat"; divMask.style.maskSize = "contain"; divMask.style.zIndex = "-1"; divMask.style.backdropFilter = "blur(10px)"; divMask.setAttribute("data-backdrop", "true"); divMask.setAttribute("class", (_b = el.getAttribute("class")) !== null && _b !== void 0 ? _b : ""); (_c = el.parentNode) === null || _c === void 0 ? void 0 : _c.insertBefore(divMask, el.nextSibling); setTimeout(() => { divMask.style.opacity = "1"; }, 0); } } } /** * Sets up the SVG renderer: initializes ResizeObserver, transition & animation listeners, * and renders SVG paths based on parent size changes. * * @param params - Object containing the target SVG element and Paths data. * @returns An object with `destroy` function to clean up observers. */ function setupSvgRenderer({ el, paths, enableBackdropBlur = false, enableViewBox = false, }) { var _a; const parentElement = (_a = findRelativeParent(el)) !== null && _a !== void 0 ? _a : el; const parentWidth = () => parentElement === null || parentElement === void 0 ? void 0 : parentElement.getBoundingClientRect().width.toString(); const parentHeight = () => parentElement === null || parentElement === void 0 ? void 0 : parentElement.getBoundingClientRect().height.toString(); const render = () => { const width = el.getBoundingClientRect().width; const height = el.getBoundingClientRect().height; createSvgElement({ el, paths, width, height, enableBackdropBlur, enableViewBox, }); }; el.render = render; // Initialize ResizeObserver to re-render on size changes const observer = new ResizeObserver((entries) => { for (let entry of entries) { entry; render(); } }); observer.observe(el); // Handle transitionstart → re-render until transition ends parentElement.addEventListener("transitionstart", () => { console.log("run"); let running = true; function loop() { if (!running) return; render(); requestAnimationFrame(loop); } loop(); parentElement.addEventListener("transitionend", () => { if (parentWidth().toString() == el.getAttribute("data-width") && parentHeight().toString() == el.getAttribute("data-height")) { running = false; console.log("stop"); } }, { once: true }); }); // Handle animationstart → re-render until animation ends parentElement.addEventListener("animationstart", () => { let running = true; function loop() { if (!running) return; render(); requestAnimationFrame(loop); } loop(); parentElement.addEventListener("animationend", () => { if (parentWidth().toString() == el.getAttribute("data-width") && parentHeight().toString() == el.getAttribute("data-height")) { running = false; } }, { once: true }); }); return { /** * Disconnects the ResizeObserver and cleans up listeners. */ destroy: () => observer.disconnect(), }; } export { setupSvgRenderer };