// Embedding: ?embed=1 mode (compact chrome, click to activate, Open full viewer, fullscreen), // the Embed panel with a copyable iframe snippet, and the postMessage bridge for host pages. // The bridge mirrors window.unroll; docs/EMBED.md documents the message schema. export const SITE = 'https://sharpmeow-unroll.static.hf.space'; export const SPACE = 'https://huggingface.co/spaces/SharpMeow/unroll'; const q = new URLSearchParams(location.search); const flag = (k, d) => (q.has(k) ? !['0', 'false', 'no', 'off'].includes(q.get(k).toLowerCase()) : d); export const EMBED = { on: flag('embed', false), controls: flag('controls', true), autoplay: flag('autoplay', false), framed: window.parent !== window, }; // the iframe snippet for the current view or a clean one export function snippet({ state = true, width = '100%', height = 480, base = SITE, autoplay = false, controls = true, stateParams = {} } = {}) { const u = new URL('/index.html', base); u.searchParams.set('embed', '1'); if (state) for (const [k, v] of Object.entries(stateParams)) if (v !== null && v !== undefined && v !== '') u.searchParams.set(k, v); if (autoplay) u.searchParams.set('autoplay', '1'); if (!controls) u.searchParams.set('controls', '0'); const w = typeof width === 'number' ? `${width}` : width, h = typeof height === 'number' ? `${height}` : height; const html = ``; return { url: u.href, html }; } export function setupEmbed({ $, getState, stateParams, onActivate }) { const root = document.documentElement; let active = !EMBED.on; const gate = $('embedGate'); const setActive = (on) => { if (!EMBED.on) return; active = on; root.classList.toggle('embed-active', on); gate.hidden = on; if (on && onActivate) onActivate(); }; if (EMBED.on) { root.classList.add('embed'); if (!EMBED.controls) root.classList.add('embed-nocontrols'); gate.hidden = false; // a click or tap (or Enter or Space) hands the pointer, touch and wheel to the scene; until then the host page scrolls as usual gate.addEventListener('click', () => { setActive(true); $('gl').focus({ preventScroll: true }); }); gate.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); gate.click(); } }); // clicking back on the host page gives scrolling back to it addEventListener('blur', () => setTimeout(() => { if (!document.hasFocus()) setActive(false); }, 0)); const full = $('embedFull'); const refresh = () => { const u = new URL(location.href); for (const k of ['embed', 'controls', 'autoplay']) u.searchParams.delete(k); const p = stateParams(); for (const [k, v] of Object.entries(p)) { if (v === null || v === undefined || v === '') u.searchParams.delete(k); else u.searchParams.set(k, v); } u.hash = ''; full.href = u.href; }; refresh(); setInterval(refresh, 1000); } // Embed panel const opt = () => ({ state: $('embedState').checked, autoplay: $('embedAutoplay').checked, controls: !$('embedNoControls').checked, stateParams: stateParams() }); // the snippet box grows to its text, so the Embed sheet is the only box that scrolls (one scroll bar, 27 Oct 2026) const fit = () => { const t = $('embedCode'); if (!t || !t.offsetParent) return; t.style.height = 'auto'; t.style.height = `${t.scrollHeight + 2}px`; }; const fill = () => { const s = snippet(opt()); $('embedCode').value = s.html; $('embedPreviewURL').href = s.url; fit(); }; addEventListener('resize', fit); document.fonts?.ready.then(fit); for (const id of ['embedState', 'embedAutoplay', 'embedNoControls']) $(id).addEventListener('change', fill); $('embedCopy').addEventListener('click', async () => { fill(); let ok = false; try { await navigator.clipboard.writeText($('embedCode').value); ok = true; } catch { $('embedCode').select(); ok = document.execCommand && document.execCommand('copy'); } $('embedCopy').textContent = ok ? 'Copied' : 'Select and copy'; setTimeout(() => { $('embedCopy').textContent = 'Copy'; }, 1600); }); return { fill, isActive: () => active, setActive, info: () => ({ embedded: EMBED.on, framed: EMBED.framed, active, controls: EMBED.controls, autoplay: EMBED.autoplay }) }; } // postMessage bridge. Host pages send // { unroll: 1, id, method: 'setLayer', args: ['xray'] } -> { unroll: 1, id, type: 'result', result } // { unroll: 1, id, method: 'subscribe', args: ['change'] } -> events as { unroll: 1, type: 'event', event, payload } // { unroll: 1, id, method: 'unsubscribe', args: ['change'] } // and receive { unroll: 1, type: 'ready', version, methods } when the scene is ready. export const PRIVATE = ['open.listLocal', 'open.removeLocal']; export function setupBridge({ api, version }) { if (window.parent === window) return null; const subs = new Map(); // event -> { id, targets: Map(source -> origin) } const reply = (src, origin, msg) => { try { src.postMessage({ unroll: 1, ...msg }, origin === 'null' ? '*' : origin); } catch { /* host went away */ } }; const resolve = (path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), api); addEventListener('message', async (e) => { const d = e.data; if (!d || typeof d !== 'object' || d.unroll !== 1 || typeof d.method !== 'string' || d.type) return; const args = Array.isArray(d.args) ? d.args : []; const src = e.source, origin = e.origin; if (!src) return; if (d.method === 'subscribe' || d.method === 'unsubscribe') { const ev = String(args[0] || ''); let s = subs.get(ev); if (d.method === 'subscribe') { if (!s) { const r = await api.on(ev, (payload) => { for (const [t, o] of s.targets) reply(t, o, { type: 'event', event: ev, payload }); }); if (!r.ok) { reply(src, origin, { type: 'result', id: d.id, result: r }); return; } s = { id: r.id, targets: new Map() }; subs.set(ev, s); } s.targets.set(src, origin); reply(src, origin, { type: 'result', id: d.id, result: { ok: true, event: ev } }); } else { if (s) { s.targets.delete(src); if (!s.targets.size) { await api.off(s.id); subs.delete(ev); } } reply(src, origin, { type: 'result', id: d.id, result: { ok: true, event: ev } }); } return; } // a host page must not read or delete what this visitor keeps in their own browser if (PRIVATE.includes(d.method)) { reply(src, origin, { type: 'result', id: d.id, result: { ok: false, error: `${d.method} is not available to host pages: it touches scrolls this visitor keeps in their own browser.`, code: 'forbidden' } }); return; } if (['on', 'off', 'register'].includes(d.method)) { reply(src, origin, { type: 'result', id: d.id, result: { ok: false, error: `${d.method} is not available over postMessage; use subscribe and unsubscribe.`, code: 'invalid' } }); return; } const fn = resolve(d.method); if (typeof fn !== 'function') { reply(src, origin, { type: 'result', id: d.id, result: { ok: false, error: `Unknown method "${d.method}". Ask for schema to list them.`, code: 'invalid' } }); return; } let result; try { result = await fn(...args); } catch (err) { result = { ok: false, error: String(err && err.message || err), code: 'failed' }; } if (d.method === 'listScrolls' && result && Array.isArray(result.scrolls)) result = { ...result, scrolls: result.scrolls.filter((x) => !x.local) }; reply(src, origin, { type: 'result', id: d.id, result: JSON.parse(JSON.stringify(result)) }); }); const announce = async () => { const v = await version(); const sch = await api.schema(); reply(window.parent, '*', { type: 'ready', version: v, methods: Object.keys(sch.methods) }); }; return { announce }; }