File size: 7,986 Bytes
cc6b6d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757768f
 
 
 
cc6b6d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// 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 };
}