/** * HtmlCapture — manages cached DOM-to-canvas snapshots for individual elements. * * Uses the `html-to-image` library to rasterise DOM nodes into * canvas-ready images. The library handles style inlining, font * embedding, canvas/image conversion and all the SVG-foreignObject * plumbing internally. * * Static elements are captured once and cached. Elements with the * `data-dynamic` attribute are re-captured every frame. */ import { toCanvas } from 'html-to-image'; interface CacheEntry { canvas: HTMLCanvasElement; w: number; h: number; } /** * Document-level cache for the prefetched + embedded @font-face blocks, * shared across every LiquidGlass instance on the page. The first call * kicks off the fetch + base64 inlining; every subsequent call returns * the same Promise. Cleared via `invalidateFontEmbedCache()`. */ let _sharedFontBlocks: Promise | null = null; /** * Discard the shared font-embed cache so the next prefetch rebuilds * it from scratch. Useful when stylesheets are added at runtime. */ export function invalidateFontEmbedCache(): void { _sharedFontBlocks = null; } /** A @font-face block that has already been fetched + base64-inlined. */ interface EmbeddedFontBlock { /** The full @font-face CSS text with data-URL src. */ css: string; /** Normalised (lowercase, unquoted) font-family name. */ family: string; /** Raw font-weight descriptor, e.g. "400" or "100 900". */ weight: string; /** Raw font-style descriptor, e.g. "normal" or "italic". */ style: string; /** Parsed unicode-range codepoint ranges, or null = all codepoints. */ unicodeRanges: Array<[number, number]> | null; } // ─── @font-face descriptor parsing helpers ─── function parseFontFamily(block: string): string { const m = block.match(/font-family\s*:\s*(['"]?)([^;'"]+)\1/i); return m ? m[2].trim() : ''; } function parseFontWeight(block: string): string { const m = block.match(/font-weight\s*:\s*([^;]+)/i); return m ? m[1].trim() : '400'; } function parseFontStyle(block: string): string { const m = block.match(/font-style\s*:\s*([^;]+)/i); return m ? m[1].trim() : 'normal'; } /** * Parse the unicode-range descriptor into an array of [start, end] * codepoint pairs. Returns null when no unicode-range is specified * (meaning the block covers all codepoints). */ function parseUnicodeRange(block: string): Array<[number, number]> | null { const m = block.match(/unicode-range\s*:\s*([^;]+)/i); if (!m) return null; const ranges: Array<[number, number]> = []; for (const part of m[1].split(',')) { const trimmed = part.trim(); // U+0400-04FF or U+0400 const rangeMatch = trimmed.match(/U\+([0-9A-Fa-f]+)(?:-([0-9A-Fa-f]+))?/); if (!rangeMatch) continue; const start = parseInt(rangeMatch[1], 16); const end = rangeMatch[2] ? parseInt(rangeMatch[2], 16) : start; ranges.push([start, end]); } return ranges.length > 0 ? ranges : null; } function weightMatches(descriptor: string, target: string): boolean { const parts = descriptor.split(/\s+/).map(Number); const t = Number(target) || 400; if (parts.length >= 2) { return t >= parts[0] && t <= parts[1]; } return parts[0] === t; } /** * Return true when at least one codepoint in `text` falls within * any of the given unicode ranges. */ function textMatchesUnicodeRange( text: string, ranges: Array<[number, number]>, ): boolean { for (let i = 0; i < text.length; i++) { const cp = text.codePointAt(i)!; for (const [lo, hi] of ranges) { if (cp >= lo && cp <= hi) return true; } // Skip the low surrogate of an astral codepoint. if (cp > 0xFFFF) i++; } return false; } // ─── Per-element font usage detection ─── interface FontUsage { family: string; weight: string; style: string; /** The concatenated text content rendered with this font combo. */ text: string; } /** * Walk an element's subtree and collect the unique font-family + * font-weight + font-style combinations actually applied to text- * bearing nodes, along with the text content rendered at each * combination. The per-combo text is used for precise unicode-range * filtering: a block for "Inter weight 700 U+0400-04FF" is only * included if the element actually has Cyrillic text at weight 700. */ function collectFontUsage(element: HTMLElement): FontUsage[] { /** key = "family|weight|style", value = index in `fonts` */ const indexMap = new Map(); const fonts: FontUsage[] = []; function walk(node: Node): void { if (node.nodeType === 3) { const content = node.textContent || ''; if (content.trim() === '') return; const parent = node.parentElement; if (!parent) return; const style = getComputedStyle(parent); const weight = style.fontWeight; const fontStyle = style.fontStyle; for (const raw of style.fontFamily.split(',')) { const family = raw.replace(/['"]/g, '').trim().toLowerCase(); const key = `${family}|${weight}|${fontStyle}`; const idx = indexMap.get(key); if (idx !== undefined) { fonts[idx].text += content; } else { indexMap.set(key, fonts.length); fonts.push({ family, weight, style: fontStyle, text: content }); } } } else if (node.nodeType === 1) { const el = node as HTMLElement; for (let i = 0; i < el.childNodes.length; i++) { walk(el.childNodes[i]); } } } walk(element); return fonts; } /** * Filter a list of embedded @font-face blocks to only those that * are actually needed by a specific element: * * 1. The block's family + weight + style must match a computed * style found on a text-bearing node inside the element. * 2. If the block declares a unicode-range, at least one * codepoint in the *matching text* (not all text in the * element — just the text rendered at that family/weight/style) * must fall within it. * * This ensures that e.g. a Cyrillic-range block for "Inter 700" is * only included when the element actually has Cyrillic text at * weight 700, not just because some other text node at weight 400 * happens to contain a Cyrillic character. */ function filterFontBlocksForElement( blocks: EmbeddedFontBlock[], element: HTMLElement, ): EmbeddedFontBlock[] { const usages = collectFontUsage(element); if (usages.length === 0) return []; return blocks.filter((block) => { // 1. Find all usages that match this block's family + weight + // style. There may be more than one (e.g. the element has // two s at the same family/weight/style but the // walker split them into separate text nodes — those get // merged via the indexMap, so normally it's just one). const matchingUsages = usages.filter((u) => { if (u.family !== block.family) return false; const styleOk = block.style === u.style || (block.style === 'normal' && u.style === 'normal'); return styleOk && weightMatches(block.weight, u.weight); }); if (matchingUsages.length === 0) return false; // 2. Unicode-range check: only test the text from matching // usages, not all text in the element. if (block.unicodeRanges) { const hasMatch = matchingUsages.some( (u) => u.text.length > 0 && textMatchesUnicodeRange(u.text, block.unicodeRanges!), ); if (!hasMatch) return false; } return true; }); } /** * Fetch a URL and return it as a base64 data URL. * Returns null on any failure. */ async function fetchAsDataUrl(url: string): Promise { try { const res = await fetch(url); if (!res.ok) return null; const blob = await res.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); } catch { return null; } } /** * Fetch every stylesheet on the page, parse @font-face rules via the * browser's own CSSOM (not regex), pre-filter to families the browser * has actually loaded, fetch each font file as a base64 data URL, and * return parsed + embedded blocks ready for per-element filtering at * capture time. * * Uses CSSStyleSheet.replace() for parsing instead of regex so we * correctly handle comments, multi-line src descriptors, and any * other edge cases the CSS spec allows inside @font-face blocks. */ async function buildFontBlocks(): Promise { // 1. Collect raw @font-face rule CSS texts via CSSOM. const fontFaceRules: string[] = []; // 1a. Fetch every and parse it via a // temporary CSSStyleSheet — avoids cross-origin CSSOM issues. const links = Array.from( document.querySelectorAll('link[rel="stylesheet"]'), ); for (const link of links) { if (!link.href) continue; try { const res = await fetch(link.href, { cache: 'force-cache' }); if (!res.ok) continue; const cssText = await res.text(); const sheet = new CSSStyleSheet(); await sheet.replace(cssText); for (const rule of sheet.cssRules) { if (rule.type === CSSRule.FONT_FACE_RULE) { fontFaceRules.push(rule.cssText); } } } catch { // Network error, CORS blocked, or replace() failed — skip. } } // 1b. Pick up inline same-origin @font-face rules. for (const sheet of Array.from(document.styleSheets)) { if (sheet.href) continue; try { for (const rule of Array.from(sheet.cssRules || [])) { if (rule.type === CSSRule.FONT_FACE_RULE) { fontFaceRules.push(rule.cssText); } } } catch { // SecurityError — skip. } } // 2. Pre-filter: only keep blocks whose font-family the browser // has actually loaded. Avoids fetching font files for families // the page never renders (e.g. an icon font in a stylesheet // that no glass element sits on top of). const loadedFamilies = new Set(); if (document.fonts) { for (const ff of document.fonts) { if (ff.status === 'loaded') { loadedFamilies.add( ff.family.replace(/['"]/g, '').trim().toLowerCase(), ); } } } const candidates = loadedFamilies.size > 0 ? fontFaceRules.filter((r) => loadedFamilies.has(parseFontFamily(r).toLowerCase())) : fontFaceRules; // 3. For each surviving rule, parse its descriptors, fetch its // font file(s) as base64 data URLs, and produce an // EmbeddedFontBlock. const embedded = await Promise.all( candidates.map(async (ruleText) => { // Replace every url(...) with a base64 data URL. const urlRegex = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g; const urlMatches = Array.from(ruleText.matchAll(urlRegex)); let css = ruleText; for (const m of urlMatches) { const url = m[1]; if (url.startsWith('data:')) continue; const dataUrl = await fetchAsDataUrl(url); if (dataUrl) { css = css.replace(m[0], `url(${dataUrl})`); } } return { css, family: parseFontFamily(ruleText).toLowerCase(), weight: parseFontWeight(ruleText), style: parseFontStyle(ruleText), unicodeRanges: parseUnicodeRange(ruleText), } satisfies EmbeddedFontBlock; }), ); return embedded; } export class HtmlCapture { readonly root: HTMLElement; readonly cache: Map; dpr: number; /** Elements with an in-flight html-to-image re-capture (dedupe). */ private readonly _capturing = new Set(); /** * Optional callback fired when an async re-capture finishes and * the cache changes. Receives the element whose cache entry was * just (re)written so the consumer can scope its dirty marking * to glasses that actually intersect that element. */ onCacheUpdate: ((element: HTMLElement) => void) | null = null; /** * Prefetched + embedded @font-face blocks. Computed once at init * via prefetchFontEmbedCSS. At capture time, filtered per-element * to include only the blocks whose family/weight/style/unicode-range * match the element's actual text content and computed styles. */ private _fontBlocks: EmbeddedFontBlock[] = []; constructor(root: HTMLElement) { this.root = root; this.cache = new Map(); this.dpr = 1; } // ──────────────────────────────────────────── // Public API // ──────────────────────────────────────────── /** * Resolve the page's @font-face rules into a single CSS string with * every `url(...)` source already inlined as a base64 data URL. The * result is reused on every subsequent toCanvas call so the captured * raster renders text with the page's actual webfonts (e.g. Inter) * instead of system fallbacks. Matching glyph metrics is what makes * the refracted text line up with the live DOM under the glass. * * The build is shared at module scope across every LiquidGlass * instance — the first init() pays the fetch + base64 cost, every * subsequent init() awaits the same Promise. * * Implemented manually rather than via html-to-image's getFontEmbedCSS * because that path walks document.styleSheets via CSSOM, which throws * SecurityError on every cross-origin stylesheet and has a brittle * recovery flow. We just fetch each directly * (CORS-friendly for the typical Google Fonts / CDN cases), regex out * the @font-face blocks, and inline each url(...) ourselves. */ async prefetchFontEmbedCSS(): Promise { if (!_sharedFontBlocks) { _sharedFontBlocks = buildFontBlocks(); } this._fontBlocks = await _sharedFontBlocks; } /** * Return the @font-face CSS string for a specific element, * filtered to only the blocks whose family + weight + style * match computed styles on the element's text nodes, AND whose * unicode-range covers at least one codepoint in the element's * text content. */ fontEmbedCSSForElement(element: HTMLElement): string { if (this._fontBlocks.length === 0) return ''; const relevant = filterFontBlocksForElement(this._fontBlocks, element); return relevant.map((b) => b.css).join('\n'); } /** * Update the device pixel ratio used for future captures. */ resize(dpr = 1): void { this.dpr = dpr; // Invalidate all caches on resize since element sizes change. this.cache.clear(); } /** * Ensure an element's cached canvas is fresh enough for the current DPR. * * Cache semantics: * - Fresh hit (size matches within 0.5 px) → return immediately. * - Stale hit (size differs) → keep the stale entry so callers can * stretch-blit it, and kick off an async re-capture. * - Cache miss → kick off an async capture. * * Concurrent re-captures for the same element are deduplicated * via the `_capturing` set, so calling this every frame is cheap. */ async captureElement(element: HTMLElement, force = false): Promise { const rect = element.getBoundingClientRect(); const cssW = rect.width; const cssH = rect.height; const w = Math.round(cssW * this.dpr); const h = Math.round(cssH * this.dpr); // Hidden / collapsed element — nothing to capture. if (w <= 0 || h <= 0) { this.cache.delete(element); return; } const cached = this.cache.get(element); const cacheIsFresh = !!cached && cached.canvas.width > 0 && cached.canvas.height > 0 && Math.abs(cached.w - w) < 0.5 && Math.abs(cached.h - h) < 0.5; if (!force && cacheIsFresh) return; // Dedupe concurrent re-captures for the same element. The // previous in-flight call will overwrite the cache when done. if (this._capturing.has(element)) return; // Canvas elements are drawn directly via the fast path. if (element.tagName === 'CANVAS') { return; } this._capturing.add(element); try { await this._captureWithHtmlToImage(element, w, h, cssW, cssH); } finally { this._capturing.delete(element); } } /** * Draw the current cached capture for an element into an arbitrary * 2D canvas. Returns true when a cached snapshot was available. */ drawCachedElement( element: HTMLElement, targetCtx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, ): boolean { const cached = this.cache.get(element); if (!cached) return false; if (cached.canvas.width <= 0 || cached.canvas.height <= 0) { this.cache.delete(element); return false; } targetCtx.drawImage(cached.canvas, x, y, w, h); return true; } /** * Capture an element's DOM content as a standalone canvas, optionally * excluding specified child nodes from the capture. * * The hideNodes are pruned from the cloned tree via html-to-image's * filter callback, so the live DOM is never mutated and there is no * visible flicker on the page even when this runs inside the render * loop (e.g. on a re-capture triggered by a content change). */ async captureToCanvas( element: HTMLElement, cssW: number, cssH: number, hideNodes: HTMLElement[] | null = null, ): Promise { if (cssW <= 0 || cssH <= 0) return null; const hideSet: Set | null = hideNodes && hideNodes.length ? new Set(hideNodes) : null; try { const rendered = await toCanvas(element, { width: cssW, height: cssH, pixelRatio: this.dpr, backgroundColor: undefined, // Reuse the prefetched font embed CSS so the per-glass // content image (used for compositing labels on top of // the shader output) uses the same Inter face the live // page does. Skips html-to-image's noisy CSSOM walk. fontEmbedCSS: this.fontEmbedCSSForElement(element), filter: hideSet ? (node: HTMLElement) => !hideSet.has(node) : undefined, style: { position: 'static', top: 'auto', left: 'auto', right: 'auto', bottom: 'auto', transform: 'none', margin: '0', }, }); return rendered; } catch (err) { console.warn('LiquidGlass: captureToCanvas failed for element:', element, err); return null; } } /** * Remove an element's entry from the capture cache. */ invalidateCache(element: HTMLElement): void { this.cache.delete(element); } /** Destroy the capture system and free resources. */ destroy(): void { this.cache.clear(); } // ──────────────────────────────────────────── // html-to-image back-end // ──────────────────────────────────────────── private async _captureWithHtmlToImage( element: HTMLElement, w: number, h: number, cssW: number, cssH: number, ): Promise { // Defensive: skip zero-sized captures. captureElement() already // guards this but the html-to-image path is reachable from // elsewhere, and a 0×0 toCanvas call returns a 0×0 canvas that // will throw on every subsequent drawImage. if (cssW <= 0 || cssH <= 0 || w <= 0 || h <= 0) return; try { const rendered = await toCanvas(element, { width: cssW, height: cssH, pixelRatio: this.dpr, // Per-element font embed CSS so the captured raster // uses the page's actual webfont at the correct weight // and unicode subset for this element's text content. fontEmbedCSS: this.fontEmbedCSSForElement(element), }); this.cache.set(element, { canvas: rendered, w, h }); this.onCacheUpdate?.(element); } catch (err) { console.warn('LiquidGlass: html-to-image capture failed for element:', element, err); } } }