unroll / lib /embed.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
7.99 kB
// 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 = `<iframe src="${u.href.replace(/&/g, '&amp;')}" title="Unroll: a Herculaneum scroll in 3D" width="${w}" height="${h}" style="border:0;max-width:100%;aspect-ratio:auto" allow="fullscreen; clipboard-write" allowfullscreen loading="lazy"></iframe>`;
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 };
}