// the reasoning for this is to generate blur a single time instead of relying on CSS filter. // CSS blur() filter can re-render the blur unpredictably and it's render behavior varys based on engine: // https://tangled.org/devins.page/tinysub/issues/112 import { blurRGBA } from "glur"; const blurLoadingClass = "blur-loading"; type BlurSource = { data: Uint8ClampedArray; width: number; height: number; }; const sourceCache = new WeakMap< HTMLImageElement, { src: string; source: BlurSource } >(); const getBlurSource = ( image: HTMLImageElement, src: string, ): BlurSource | null => { const cached = sourceCache.get(image); if (cached?.src === src) return cached.source; if (!image.naturalWidth || !image.naturalHeight) return null; const scale = Math.min( 1, 128 / Math.max(image.naturalWidth, image.naturalHeight), ); const width = Math.max(1, Math.round(image.naturalWidth * scale)); const height = Math.max(1, Math.round(image.naturalHeight * scale)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return null; context.drawImage(image, 0, 0, width, height); const source = { data: context.getImageData(0, 0, width, height).data, width, height, }; sourceCache.set(image, { src, source }); return source; }; const createBlurredDataUrl = (source: BlurSource, radius: number) => { const data = new Uint8ClampedArray(source.data); blurRGBA(data, source.width, source.height, radius); const canvas = document.createElement("canvas"); canvas.width = source.width; canvas.height = source.height; const context = canvas.getContext("2d"); if (!context) return ""; context.putImageData(new ImageData(data, source.width, source.height), 0, 0); return canvas.toDataURL(); }; export const blurImage = ( image: HTMLImageElement, src: string, radius: number, onReady: () => void, ) => { image.classList.add(blurLoadingClass); let blurredSrc = ""; let isIntersecting = false; let isReady = false; const reveal = () => image.classList.remove(blurLoadingClass); const restore = () => { if (image.isConnected && blurredSrc && image.src === blurredSrc) image.src = src; blurredSrc = ""; }; const signalReady = () => { reveal(); if (isReady) return; isReady = true; onReady(); }; const apply = () => { if ( isReady || blurredSrc || document.visibilityState !== "visible" || !isIntersecting ) return; image.classList.add(blurLoadingClass); try { const source = getBlurSource(image, src); if (!source) return; blurredSrc = createBlurredDataUrl(source, radius); if (blurredSrc) image.src = blurredSrc; else signalReady(); } catch { signalReady(); } }; const handleLoad = () => { if (blurredSrc) signalReady(); else apply(); }; const handleError = () => { if (blurredSrc) restore(); signalReady(); }; const visibilityObserver = new IntersectionObserver(([entry]) => { isIntersecting = entry?.isIntersecting ?? false; apply(); }); image.addEventListener("load", handleLoad); image.addEventListener("error", handleError); document.addEventListener("visibilitychange", apply); visibilityObserver.observe(image); if (image.complete) { if (image.naturalWidth) handleLoad(); else handleError(); } return () => { image.removeEventListener("load", handleLoad); image.removeEventListener("error", handleError); document.removeEventListener("visibilitychange", apply); visibilityObserver.disconnect(); restore(); reveal(); }; };