Spaces:
Sleeping
Sleeping
| // --------------------------------------------------------------------------- | |
| // Shared embed document builder | |
| // | |
| // Single source of truth for the srcdoc injected into every embed iframe, | |
| // used by both the editor (live preview) and the publisher (static HTML). | |
| // | |
| // Exports: | |
| // - buildEmbedSrcdoc(html, opts) – assemble the full HTML doc | |
| // - EMBED_MESSAGE_TYPES – { resize, setTheme, updatePrimaryColor } | |
| // - DEFAULT_EMBED_HEIGHT – fallback pixel height when none stored | |
| // | |
| // Why one shared builder? | |
| // Before, the editor and publisher each had their own COLOR_PALETTES / height | |
| // reporter / theme listener, with subtle divergences (e.g. only the publisher | |
| // supported setTheme hot-swap). Keeping them in sync by hand was a footgun. | |
| // --------------------------------------------------------------------------- | |
| export const DEFAULT_EMBED_HEIGHT = 400; | |
| export const EMBED_MESSAGE_TYPES = { | |
| resize: "embedResize", | |
| setTheme: "setTheme", | |
| updatePrimaryColor: "updatePrimaryColor", | |
| ready: "embedReady", | |
| } as const; | |
| /** ColorPalettes polyfill – mirrors the research-article-template API. */ | |
| const COLOR_PALETTES_POLYFILL = ` | |
| (function(){ | |
| var palettes = { | |
| categorical: ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ac'], | |
| sequential: ['#084594','#2171b5','#4292c6','#6baed6','#9ecae1','#c6dbef','#deebf7','#f7fbff'], | |
| diverging: ['#d73027','#f46d43','#fdae61','#fee090','#ffffbf','#e0f3f8','#abd9e9','#74add1','#4575b4'] | |
| }; | |
| window.ColorPalettes = { | |
| getColors: function(type, n) { | |
| var p = palettes[type] || palettes.categorical; | |
| return p.slice(0, n != null ? n : p.length); | |
| }, | |
| getPrimary: function() { | |
| return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4e79a7'; | |
| }, | |
| refresh: function() {}, | |
| getTextStyleForBackground: function() { return { fill: '#ffffff' }; } | |
| }; | |
| })(); | |
| `.trim(); | |
| /** | |
| * Height reporter: | |
| * - Observes documentElement + body (ResizeObserver) to catch all reflows | |
| * - Observes body's subtree (MutationObserver) to catch async chart mounts | |
| * - Throttles via requestAnimationFrame and only posts when delta >= 1px | |
| * - Safety retries at load, +300ms, +1000ms, +3000ms for slow async charts | |
| * - Exposes window.__embedReady(optionalHeight) so charts can opt-in to a | |
| * deterministic "I'm done" signal (useful for CSV fetches > 3s) | |
| */ | |
| const HEIGHT_REPORTER = ` | |
| (function(){ | |
| var lastH = 0; | |
| var scheduled = false; | |
| // IMPORTANT: only measure <body>. document.documentElement.scrollHeight is | |
| // clamped to at least the iframe's viewport height, so it would never allow | |
| // the iframe to shrink (it would always report >= current iframe height). | |
| // body.scrollHeight reflects the actual content height plus body padding. | |
| function measure(){ | |
| var b = document.body; | |
| if (!b) return 0; | |
| // getBoundingClientRect().bottom accounts for padding/margin precisely; | |
| // fall back to scrollHeight if the rect reports 0 (detached layout). | |
| var rectBottom = Math.ceil(b.getBoundingClientRect().bottom); | |
| var scroll = Math.ceil(b.scrollHeight); | |
| // body margin may push the rect down; scrollHeight ignores it. Take the | |
| // max so we never under-report. | |
| return Math.max(rectBottom, scroll); | |
| } | |
| function post(h){ | |
| try { | |
| window.parent.postMessage({ type: 'embedResize', height: h }, '*'); | |
| } catch(e){} | |
| } | |
| function schedule(){ | |
| if (scheduled) return; | |
| scheduled = true; | |
| requestAnimationFrame(function(){ | |
| scheduled = false; | |
| var h = measure(); | |
| if (h > 0 && Math.abs(h - lastH) >= 1) { | |
| lastH = h; | |
| post(h); | |
| } | |
| }); | |
| } | |
| if (typeof ResizeObserver !== 'undefined') { | |
| var ro = new ResizeObserver(schedule); | |
| ro.observe(document.documentElement); | |
| if (document.body) ro.observe(document.body); | |
| } | |
| if (typeof MutationObserver !== 'undefined' && document.body) { | |
| var mo = new MutationObserver(schedule); | |
| mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); | |
| } | |
| window.addEventListener('load', function(){ | |
| schedule(); | |
| setTimeout(schedule, 300); | |
| setTimeout(schedule, 1000); | |
| setTimeout(schedule, 3000); | |
| }); | |
| // Opt-in explicit ready signal for async charts | |
| window.__embedReady = function(){ | |
| schedule(); | |
| try { window.parent.postMessage({ type: 'embedReady' }, '*'); } catch(e){} | |
| }; | |
| })(); | |
| `.trim(); | |
| /** | |
| * Synchronous pre-paint theme bootstrap. | |
| * | |
| * Runs BEFORE the iframe's own <style> applies, so the first paint already | |
| * uses the correct theme + primary color and the banner/chart never flashes | |
| * from the default (light / #4e79a7) to the user-selected theme. | |
| * | |
| * Strategy (tried in order, first hit wins): | |
| * 1. parent.documentElement[data-theme] + parent's computed --primary-color | |
| * → works in published articles, the editor, and any embed sharing | |
| * origin with its host (we rely on `allow-same-origin` being set). | |
| * 2. localStorage.theme (same origin as parent). | |
| * 3. matchMedia('(prefers-color-scheme: dark)'). | |
| * | |
| * All accesses are wrapped in try/catch: if the iframe is cross-origin or | |
| * sandboxed, we silently fall back to the server-provided defaults baked | |
| * into the srcdoc (`data-theme`, `--primary-color`) and the postMessage | |
| * setTheme path still works as a safety net. | |
| */ | |
| const THEME_BOOTSTRAP = ` | |
| (function(){ | |
| try { | |
| var theme = null; | |
| var primary = null; | |
| try { | |
| var parentDoc = window.parent && window.parent.document; | |
| if (parentDoc) { | |
| theme = parentDoc.documentElement.getAttribute('data-theme') || null; | |
| var pc = getComputedStyle(parentDoc.documentElement).getPropertyValue('--primary-color'); | |
| if (pc) primary = pc.trim(); | |
| } | |
| } catch(e) {} | |
| if (!theme) { | |
| try { theme = window.localStorage && window.localStorage.getItem('theme'); } catch(e) {} | |
| } | |
| // Respect the srcdoc-provided data-theme before falling back to the | |
| // OS preference. Without this, a cross-origin sandbox iframe (e.g. | |
| // an embed-studio preview with sandbox="allow-scripts" but no | |
| // allow-same-origin) can't read the parent and the bootstrap would | |
| // otherwise clobber the explicitly-set initial theme with | |
| // matchMedia's guess. | |
| if (!theme) { | |
| theme = document.documentElement.getAttribute('data-theme') || null; | |
| } | |
| if (!theme) { | |
| theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| } | |
| document.documentElement.setAttribute('data-theme', theme); | |
| document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'; | |
| if (primary) document.documentElement.style.setProperty('--primary-color', primary); | |
| } catch(e) {} | |
| })(); | |
| `.trim(); | |
| /** | |
| * Theme listener: swap CSS variables via [data-theme] without reloading. | |
| * Also supports live primary-color updates and invokes window.__chartRerender | |
| * if the embed defines one (same convention as research-article-template). | |
| */ | |
| const THEME_LISTENER = ` | |
| (function(){ | |
| window.addEventListener('message', function(e){ | |
| if (!e.data) return; | |
| if (e.data.type === 'setTheme') { | |
| document.documentElement.setAttribute('data-theme', e.data.theme); | |
| document.documentElement.style.colorScheme = e.data.theme === 'dark' ? 'dark' : 'light'; | |
| if (e.data.primaryColor) { | |
| document.documentElement.style.setProperty('--primary-color', e.data.primaryColor); | |
| } | |
| if (window.__chartRerender) try { window.__chartRerender(); } catch(err){} | |
| } else if (e.data.type === 'updatePrimaryColor') { | |
| document.documentElement.style.setProperty('--primary-color', e.data.color); | |
| if (window.__chartRerender) try { window.__chartRerender(); } catch(err){} | |
| } | |
| }); | |
| })(); | |
| `.trim(); | |
| const BASE_STYLES = ` | |
| :root, [data-theme='light'] { | |
| color-scheme: light; | |
| --text-color: rgba(0,0,0,.85); | |
| --surface-bg: #f9f9f9; | |
| --page-bg: #fff; | |
| --border-color: rgba(0,0,0,.1); | |
| --muted-color: rgba(0,0,0,.6); | |
| --axis-color: rgba(0,0,0,.6); | |
| --tick-color: rgba(0,0,0,.85); | |
| --grid-color: rgba(0,0,0,.08); | |
| } | |
| [data-theme='dark'] { | |
| color-scheme: dark; | |
| --text-color: rgba(255,255,255,.9); | |
| --surface-bg: #07080a; | |
| --page-bg: #0f1115; | |
| --border-color: rgba(255,255,255,.15); | |
| --muted-color: rgba(255,255,255,.7); | |
| --axis-color: rgba(255,255,255,.7); | |
| --tick-color: rgba(255,255,255,.7); | |
| --grid-color: rgba(255,255,255,.10); | |
| } | |
| *, *::before, *::after { box-sizing: border-box; } | |
| /* IMPORTANT: do NOT set min-height:100% on <body>. Doing so creates a | |
| lower bound on scrollHeight equal to the iframe's rendered height, so | |
| the reporter can never signal a smaller height and the iframe can only | |
| ever grow (never shrink when the chart re-renders narrower on resize). */ | |
| html, body { margin: 0; padding: 0; background: transparent; } | |
| body { | |
| color: var(--text-color); | |
| font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif; | |
| font-size: 13px; | |
| padding: 16px; | |
| /* overflow: clip clips runaway horizontal content without creating a | |
| scroll container that would trap scroll events or inflate scrollHeight. */ | |
| overflow: clip; | |
| } | |
| /* SVG quirk: text/line/path do not inherit CSS color the way HTML does. | |
| Their default fill/stroke is "black", not "currentColor". A chart | |
| that only sets body color via var(--text-color) would still render | |
| black axes/labels in dark mode. Default everything without its own | |
| fill/stroke to currentColor so the cascaded body color drives the | |
| look. Charts that want a specific colour just set the attribute or | |
| inline style and win by specificity. */ | |
| svg text:not([fill]) { fill: currentColor; } | |
| svg .axis path:not([stroke]), | |
| svg .axis line:not([stroke]), | |
| svg .domain:not([stroke]) { stroke: currentColor; } | |
| svg .grid line:not([stroke]) { stroke: currentColor; opacity: 0.12; } | |
| `.trim(); | |
| /** | |
| * Escape `</script>` inside the embed HTML fragment so the outer parser does | |
| * not terminate its own <script> block prematurely. Keeps the final closing | |
| * tag intact in case the embed itself ends with `</script>`. | |
| */ | |
| function escapeScriptTags(html: string): string { | |
| const escaped = html.replace(/<\/script>/gi, "<\\/script>"); | |
| const lastIdx = escaped.lastIndexOf("<\\/script>"); | |
| if (lastIdx === -1) return html; | |
| return escaped.slice(0, lastIdx) + "</script>" + escaped.slice(lastIdx + 10); | |
| } | |
| export interface BuildEmbedSrcdocOptions { | |
| /** Initial theme, before any setTheme postMessage is received. */ | |
| isDark?: boolean; | |
| /** Initial primary color (CSS color), defaults to #4e79a7. */ | |
| primaryColor?: string; | |
| /** | |
| * Edge-to-edge mode (used for article banners): | |
| * - html/body fill 100% of the iframe viewport | |
| * - no body padding (chart bleeds to the edges) | |
| * - height reporter is skipped (iframe is size-driven by its parent's | |
| * aspect-ratio, not by the chart's scrollHeight) | |
| */ | |
| fullBleed?: boolean; | |
| } | |
| /** | |
| * Stylesheet override applied when `fullBleed` is true. Replaces the | |
| * default body padding and establishes a proper 100%-height chain so | |
| * charts declaring `height: 100%` actually get a measurable height. | |
| */ | |
| const FULL_BLEED_STYLES = ` | |
| html, body { height: 100%; width: 100%; } | |
| body { padding: 0 !important; overflow: hidden; } | |
| body > :first-child { width: 100%; height: 100%; } | |
| `.trim(); | |
| /** | |
| * Build a self-contained HTML document suitable for iframe `srcdoc=`. | |
| * Theme changes after load should be driven via postMessage (`setTheme`) | |
| * rather than rebuilding the srcdoc – otherwise the iframe reloads and loses | |
| * chart state. | |
| */ | |
| export function buildEmbedSrcdoc(htmlFragment: string, opts: BuildEmbedSrcdocOptions = {}): string { | |
| const isDark = !!opts.isDark; | |
| const primaryColor = opts.primaryColor || "#4e79a7"; | |
| const themeAttr = isDark ? "dark" : "light"; | |
| const inlinePrimary = `:root{--primary-color:${primaryColor};}`; | |
| const fullBleed = !!opts.fullBleed; | |
| const extraStyles = fullBleed ? FULL_BLEED_STYLES : ""; | |
| const parts: string[] = [ | |
| "<!DOCTYPE html>", | |
| `<html data-theme="${themeAttr}">`, | |
| "<head>", | |
| '<meta charset="UTF-8">', | |
| // Pre-paint theme bootstrap MUST come before the <style> block so the | |
| // correct [data-theme] attribute is in place when the default tokens | |
| // cascade applies. Otherwise the iframe flashes from server-default to | |
| // client theme once the postMessage listener kicks in post-load. | |
| `<script>${THEME_BOOTSTRAP}<\/script>`, | |
| `<style>${BASE_STYLES}${inlinePrimary}${extraStyles}</style>`, | |
| `<script>${COLOR_PALETTES_POLYFILL}<\/script>`, | |
| "</head>", | |
| "<body>", | |
| escapeScriptTags(htmlFragment), | |
| ]; | |
| // Skip the height reporter in fullBleed mode: the iframe is size-driven | |
| // by its parent's aspect-ratio (e.g. the banner 5:2 box) and must not | |
| // try to resize itself based on chart scrollHeight. | |
| if (!fullBleed) { | |
| parts.push(`<script>${HEIGHT_REPORTER}<\/script>`); | |
| } | |
| parts.push(`<script>${THEME_LISTENER}<\/script>`); | |
| parts.push("</body></html>"); | |
| return parts.join("\n"); | |
| } | |