Download ui.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 41.2 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/ui.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/ui.js
-
curl -L -o ui.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/ui.js
41.2 kB
| // Control panel layout: groups the existing buttons (moved, never cloned, so every id, listener and | |
| // window hook in app.js, polish.js and lib/bench.js keeps working), adds icon + text labels, | |
| // shortcut tooltips, pressed states and measured offsets so nothing overlaps on small screens. | |
| const $ = (id) => document.getElementById(id); | |
| const nav = document.querySelector('nav.hud.tr'); | |
| const KEYS = { | |
| btnHelp: '?', btnInfo: 'I', btnRead: 'R', btnRollUp: 'U', btnTear: 'T', toolTear: 'X', btnCrush: 'C', | |
| toolBurn: 'B', toolWater: 'W', btnCrumble: 'K', play: 'Space', uiReset: 'V', uiFull: 'F', btnSettings: 'S', | |
| }; | |
| const LAYER_KEYS = { xray: '1', ink: '2', gpu: '3' }; | |
| const LABELS = { btnSettings: 'Settings', btnHelp: 'Help', btnOpen: 'Open', btnBench: 'Ink Bench', btnInfo: 'About', btnShot: 'Picture' }; | |
| function group(name, cls) { | |
| const g = document.createElement('section'); | |
| g.className = `ui-group ${cls}`; | |
| g.setAttribute('aria-label', name); | |
| g.innerHTML = `<h3 class="ui-gh">${name}</h3>`; | |
| return g; | |
| } | |
| function mk(id, label, title, onclick) { | |
| const b = document.createElement('button'); | |
| b.className = 'icon ui-btn'; b.id = id; b.type = 'button'; b.title = title; | |
| b.innerHTML = `<span class="ui-lbl">${label}</span>`; | |
| b.onclick = onclick; | |
| return b; | |
| } | |
| function build() { | |
| if (!nav || nav.dataset.ui) return; | |
| nav.dataset.ui = '1'; | |
| document.body.classList.add('ui2'); | |
| nav.setAttribute('aria-label', 'Controls'); | |
| const tabs = nav.querySelector('.seg[role=tablist]'); | |
| const hint = $('layerHint'), why = $('layerWhy'); | |
| const destroy = nav.querySelector('.tear-seg'); | |
| const tools = nav.querySelector('.tools'); | |
| // View: camera, full screen, help, about, picture, open your own, bench | |
| const gView = group('View', 'ui-view'); | |
| const reset = mk('uiReset', 'Reset view', 'Reset the camera view (V)', () => window.unrollView?.reset?.()); | |
| const full = mk('uiFull', 'Full screen', 'Full screen (F)', () => { | |
| if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); | |
| }); | |
| addEventListener('fullscreenchange', () => full.setAttribute('aria-pressed', !!document.fullscreenElement)); | |
| full.setAttribute('aria-pressed', 'false'); | |
| const vgrid = document.createElement('div'); vgrid.className = 'ui-grid'; | |
| const gear = mk('btnSettings', 'Settings', 'Settings: quality, motion, speed, glow, background, HUD, sensitivity, units (S)', () => toggleSettings()); | |
| gear.setAttribute('aria-haspopup', 'dialog'); gear.setAttribute('aria-expanded', 'false'); gear.setAttribute('aria-controls', 'uiSettings'); | |
| const inst = mk('uiInstall', 'Install app', 'Install Unroll as an app that works offline', () => runAction('install')); | |
| inst.hidden = !pwa.canOffer(); | |
| vgrid.append(gear, reset, full, inst); | |
| if (tools) { for (const b of [...tools.children]) { if (LABELS[b.id]) for (const n of [...b.childNodes]) if (n.nodeType === 3) n.remove(); | |
| if (LABELS[b.id] && !b.querySelector('.ui-lbl')) b.insertAdjacentHTML('beforeend', `<span class="ui-lbl">${LABELS[b.id]}</span>`); b.classList.add('ui-btn'); vgrid.append(b); } tools.remove(); } | |
| gView.append(vgrid); | |
| // Layers | |
| const gLayers = group('Layers', 'ui-layers'); | |
| gLayers.append(tabs); if (hint) gLayers.append(hint); if (why) gLayers.append(why); | |
| // Destroy tools | |
| const gDestroy = group('Destroy', 'ui-destroy'); | |
| if (destroy) gDestroy.append(destroy); | |
| // Scrolls: the picker from the header, shown only when there is more than one | |
| const pick = $('scrollPick'); | |
| const gScrolls = group('Scrolls', 'ui-scrolls'); | |
| if (pick) { | |
| gScrolls.append(pick); | |
| const sync = () => { gScrolls.hidden = pick.hidden; }; | |
| new MutationObserver(sync).observe(pick, { attributes: true, attributeFilter: ['hidden'] }); sync(); | |
| } | |
| // mobile: a toggle that folds the panel down to the layer picker | |
| const fold = document.createElement('button'); | |
| fold.className = 'chip ui-fold'; fold.type = 'button'; fold.id = 'uiFold'; | |
| fold.setAttribute('aria-expanded', 'false'); fold.setAttribute('aria-controls', 'uiBody'); | |
| fold.innerHTML = '<span class="ui-lbl">More controls</span>'; | |
| fold.title = 'Show or hide View, Destroy and Scrolls'; | |
| const body = document.createElement('div'); body.className = 'ui-body'; body.id = 'uiBody'; | |
| body.append(gScrolls, gView, gDestroy); | |
| fold.onclick = () => { const o = fold.getAttribute('aria-expanded') !== 'true'; fold.setAttribute('aria-expanded', o); nav.classList.toggle('ui-open', o); measure(); }; | |
| const bench = vgrid.querySelector('#btnBench'); | |
| if (bench) { | |
| bench.classList.add('ui-bench'); bench.title = 'Ink Bench: compare and score ink detection models on a real winding (in your browser, nothing is uploaded)'; | |
| bench.setAttribute('aria-label', 'Ink Bench: compare and score ink detection models'); | |
| } | |
| nav.replaceChildren(gLayers, ...(bench ? [bench] : []), fold, body); | |
| // feature modules (lib/ctwl.js, lib/ctvolume.js) add their own groups to the panel body | |
| window.unrollPanel = { body, group, mk, measure, addAction: (name, a) => { ACTIONS[name] = a; tagActions(); } }; | |
| dispatchEvent(new CustomEvent('unroll:panel', { detail: window.unrollPanel })); | |
| // Unroll and Roll up: label the bottom bar group | |
| const bar = document.querySelector('footer.hud.bottom'); | |
| if (bar) { bar.setAttribute('aria-label', 'Unroll and Roll up'); bar.setAttribute('role', 'group'); } | |
| // shortcuts: aria-keyshortcuts and a kbd badge in the tooltip | |
| for (const [id, k] of Object.entries(KEYS)) $(id)?.setAttribute('aria-keyshortcuts', k === 'Space' ? 'Space' : k); | |
| for (const b of document.querySelectorAll('.chip[data-layer]')) b.setAttribute('aria-keyshortcuts', LAYER_KEYS[b.dataset.layer] || ''); | |
| for (const b of document.querySelectorAll('.chip[data-layer]')) b.setAttribute('aria-selected', b.classList.contains('on')); | |
| new MutationObserver((ms) => { | |
| for (const m of ms) { const b = m.target; if (b.matches?.('.chip[data-layer]')) b.setAttribute('aria-selected', b.classList.contains('on')); } | |
| }).observe(nav, { subtree: true, attributes: true, attributeFilter: ['class'] }); | |
| tagActions(); | |
| buildSettings(); | |
| tips(); | |
| measure(); | |
| new ResizeObserver(measure).observe(nav); | |
| const tl = document.querySelector('.hud.tl'); if (tl) new ResizeObserver(measure).observe(tl); | |
| addEventListener('resize', measure); | |
| } | |
| // a tooltip that shows the button's help with its shortcut; native titles are parked while it shows | |
| function tips() { | |
| const tip = document.createElement('div'); | |
| tip.className = 'ui-tip mono'; tip.hidden = true; tip.setAttribute('role', 'tooltip'); tip.id = 'uiTip'; | |
| document.body.append(tip); | |
| let cur = null; | |
| const keyOf = (b) => b.getAttribute('aria-keyshortcuts') || ''; | |
| const show = (b) => { | |
| if (matchMedia('(pointer: coarse)').matches && !b.matches(':focus-visible')) return; | |
| const t = b.title || b.dataset.uiTitle || ''; | |
| if (!t) return; | |
| if (b.title) { b.dataset.uiTitle = b.title; b.removeAttribute('title'); } | |
| const k = keyOf(b); | |
| tip.innerHTML = ''; | |
| const s = document.createElement('span'); s.textContent = t.replace(/\s*\(([A-Z?]|Space)\)\s*$/, ''); tip.append(s); | |
| if (k) { const kb = document.createElement('kbd'); kb.textContent = k; tip.append(kb); } | |
| tip.hidden = false; cur = b; | |
| b.setAttribute('aria-describedby', 'uiTip'); | |
| const r = b.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight; | |
| let x = Math.min(innerWidth - w - 8, Math.max(8, r.left + r.width / 2 - w / 2)); | |
| let y = r.top - h - 8; if (y < 8) y = r.bottom + 8; | |
| if (nav.contains(b) && innerWidth > 720) { x = Math.max(8, r.left - w - 10); y = Math.min(innerHeight - h - 8, Math.max(8, r.top + r.height / 2 - h / 2)); } | |
| tip.style.left = `${x}px`; tip.style.top = `${y}px`; | |
| }; | |
| const hide = (b) => { | |
| if (b && b.dataset.uiTitle && !b.title) b.title = b.dataset.uiTitle; | |
| if (b) b.removeAttribute('aria-describedby'); | |
| tip.hidden = true; cur = null; | |
| }; | |
| const sel = 'button[title], button[data-ui-title]'; | |
| document.addEventListener('pointerover', (e) => { const b = e.target.closest?.(sel); if (b && b !== cur) { if (cur) hide(cur); show(b); } }); | |
| document.addEventListener('pointerout', (e) => { const b = e.target.closest?.(sel); if (b && !b.contains(e.relatedTarget)) hide(b); }); | |
| document.addEventListener('focusin', (e) => { const b = e.target.closest?.(sel); if (b) show(b); }); | |
| document.addEventListener('focusout', (e) => { const b = e.target.closest?.(sel); if (b) hide(b); }); | |
| document.addEventListener('pointerdown', () => cur && hide(cur), true); | |
| } | |
| // measured offsets: the readout and credit sit above or below the panel instead of under it | |
| function measure() { | |
| const st = document.documentElement.style; | |
| st.setProperty('--ui-panel-h', `${Math.ceil(nav.getBoundingClientRect().height)}px`); | |
| const tl = document.querySelector('.hud.tl'); | |
| const cr = document.querySelector('.credit'); | |
| const hb = Math.max(tl ? tl.getBoundingClientRect().bottom : 0, cr && innerWidth <= 720 ? cr.getBoundingClientRect().bottom : 0); | |
| if (tl) st.setProperty('--ui-head-b', `${Math.ceil(tl.getBoundingClientRect().bottom)}px`); | |
| // the Read box sits 10 px above the docked panel on phones; the panel's max-height leaves room for it | |
| const ro = document.getElementById('readout'), rh = ro ? ro.getBoundingClientRect().height : 0; | |
| st.setProperty('--ui-bl-h', `${rh ? Math.ceil(rh) + 10 : 0}px`); | |
| st.setProperty('--ui-top-b', `${Math.ceil(hb)}px`); | |
| } | |
| // polish.js adds the help button after load; wait for it so it lands in the View group | |
| // ---------------------------------------------------------------- actions: one stable name per control | |
| // Every control gets data-action, an aria-label and a function in window.unrollView.actions. | |
| const ACTIONS = { | |
| play: { id: 'play', label: 'Play or pause the unroll', run: (on) => { const p = isPlaying(); if (on === undefined || !!on !== p) $('play').click(); } }, | |
| rollUp: { id: 'btnRollUp', label: 'Roll it back up (again to stop)' }, | |
| seek: { label: 'Set the unroll position, 0 rolled to 1 unrolled', run: (v) => { const r = $('scrubIn'); r.value = Math.round(Math.max(0, Math.min(1, +v || 0)) * 1000); r.dispatchEvent(new Event('input', { bubbles: true })); r.dispatchEvent(new Event('change', { bubbles: true })); } }, | |
| 'layer.xray': { sel: '.chip[data-layer=xray]', label: 'Layer: X-ray' }, | |
| 'layer.ink': { sel: '.chip[data-layer=ink]', label: 'Layer: published ink' }, | |
| 'layer.gpu': { sel: '.chip[data-layer=gpu]', label: 'Layer: ink_9um (9 µm)' }, | |
| read: { id: 'btnRead', label: 'Read the current winding close up' }, | |
| resetView: { id: 'uiReset', label: 'Reset the camera view' }, | |
| fullscreen: { id: 'uiFull', label: 'Full screen on or off' }, | |
| install: { id: 'uiInstall', label: 'Install Unroll as an app (works offline)', run: () => pwa.install() }, | |
| settings: { id: 'btnSettings', label: 'Open or close Settings', run: (on) => toggleSettings(on) }, | |
| help: { id: 'btnHelp', label: 'Keyboard shortcuts' }, | |
| about: { id: 'btnInfo', label: 'About this page' }, | |
| open: { id: 'btnOpen', label: 'Open your own scroll' }, | |
| bench: { id: 'btnBench', label: 'Ink Bench' }, | |
| picture: { id: 'btnShot', label: 'Save a picture' }, | |
| destroy: { id: 'btnTear', label: 'Destroy mode on or off', toggle: true }, | |
| 'tool.tear': { id: 'toolTear', label: 'Destroy tool: Tear', needs: 'destroy' }, | |
| 'tool.crush': { id: 'btnCrush', label: 'Destroy tool: Crush', needs: 'destroy' }, | |
| 'tool.burn': { id: 'toolBurn', label: 'Destroy tool: Burn', needs: 'destroy' }, | |
| 'tool.water': { id: 'toolWater', label: 'Destroy tool: Water', needs: 'destroy' }, | |
| 'damage.reset': { id: 'btnReset', label: 'Undo all damage', needs: 'destroy' }, | |
| crumble: { id: 'btnCrumble', label: 'Crumble the whole scroll to dust', run: () => window.unrollView?.crumble?.() }, | |
| sandbox: { id: 'btnSandbox', label: 'Sandbox fracture physics on or off', needs: 'destroy', toggle: true }, | |
| shake: { id: 'btnShake', label: 'Knock the table', needs: 'sandbox' }, | |
| sound: { id: 'btnSound', label: 'Crackle sound on or off', needs: 'destroy', toggle: true }, | |
| skipIntro: { id: 'skipIntro', label: 'Skip the intro and start unrolling', run: () => window.unrollView?.skipIntro?.() ?? false }, | |
| moreControls: { id: 'uiFold', label: 'Show or hide View, Destroy and Scrolls (small screens)' }, | |
| prizeOnly: { id: 'prizeOnly', label: 'Show only prize eligible scrolls', toggle: true }, | |
| scroll: { label: 'Switch scroll by id (see window.unrollScrolls)', run: (id) => { const s = $('scrollSel'); if (!s || ![...s.options].some((o) => o.value === id)) return false; s.value = id; s.dispatchEvent(new Event('change', { bubbles: true })); return true; } }, | |
| }; | |
| const elOf = (a) => (a.id ? $(a.id) : a.sel ? document.querySelector(a.sel) : null); | |
| const isPlaying = () => $('play')?.classList.contains('playing'); | |
| const pressed = (el) => el?.classList.contains('on') || el?.getAttribute('aria-pressed') === 'true'; | |
| function runAction(name, arg) { | |
| const a = ACTIONS[name]; if (!a) throw new Error(`unknown action ${name}; see unrollView.actions.list()`); | |
| if (a.needs === 'destroy' && !pressed($('btnTear'))) $('btnTear').click(); | |
| if (a.needs === 'sandbox' && !pressed($('btnSandbox'))) runAction('sandbox', true); | |
| if (a.run) return a.run(arg) ?? true; | |
| const el = elOf(a); if (!el || el.hidden) return false; | |
| if (a.toggle && arg !== undefined && !!arg === pressed(el)) return true; | |
| el.click(); return true; | |
| } | |
| function tagActions() { | |
| for (const [name, a] of Object.entries(ACTIONS)) { | |
| const el = elOf(a); if (!el) continue; | |
| el.dataset.action = name; | |
| if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', a.label); | |
| } | |
| } | |
| // ---------------------------------------------------------------- settings | |
| const STORE = 'unroll.settings.v1'; | |
| const SETTINGS = { | |
| quality: { label: 'Graphics quality', type: 'select', options: ['low', 'medium', 'high'], def: matchMedia('(pointer: coarse)').matches ? 'medium' : 'high' }, | |
| reducedMotion: { label: 'Reduced motion', type: 'bool', def: matchMedia('(prefers-reduced-motion: reduce)').matches }, | |
| speed: { label: 'Animation speed', type: 'range', min: 0.25, max: 4, step: 0.25, def: 1, unit: 'x' }, | |
| bloom: { label: 'Glow intensity', type: 'range', min: 0, max: 2, step: 0.1, def: 1, unit: 'x' }, | |
| background: { label: 'Background', type: 'select', options: ['dark', 'black', 'slate', 'warm'], def: 'dark' }, | |
| hud: { label: 'Show HUD (title, readout, credit)', type: 'bool', def: true }, | |
| labels: { label: 'Show button labels', type: 'bool', def: true }, | |
| sensitivity: { label: 'Mouse and touch sensitivity', type: 'range', min: 0.25, max: 3, step: 0.25, def: 1, unit: 'x' }, | |
| inkBench: { label: 'Show the Ink Bench button', type: 'bool', def: true }, | |
| units: { label: 'Units', type: 'select', options: ['metric', 'imperial'], def: 'metric' }, | |
| ctAuto: { label: 'X-ray auto contrast on load', type: 'bool', def: false }, | |
| ctAutoLow: { label: 'Auto contrast low percentile', type: 'range', min: 0, max: 20, step: 0.5, def: 1, unit: '%' }, | |
| ctAutoHigh: { label: 'Auto contrast high percentile', type: 'range', min: 80, max: 100, step: 0.5, def: 99, unit: '%' }, | |
| ctVolSize: { label: 'CT volume cube size (voxels)', type: 'select', options: ['auto', '64', '128', '192', '256'], def: 'auto' }, | |
| ctVolDetail: { label: 'CT volume detail level (0 = full 9.4 µm scan)', type: 'select', options: ['auto', '0', '1', '2', '3', '4'], def: 'auto' }, | |
| dragRes: { label: 'Lower resolution while dragging', type: 'select', options: ['off', 'light', 'strong'], def: matchMedia('(pointer: coarse)').matches ? 'strong' : 'light' }, | |
| }; | |
| const BG = { dark: 0x030303, black: 0x000000, slate: 0x10161d, warm: 0x16100a }; | |
| let S = {}; | |
| let base = null; | |
| function coerce(k, v) { | |
| const d = SETTINGS[k]; if (!d) throw new Error(`unknown setting ${k}; see unrollView.settings.list()`); | |
| if (d.type === 'bool') return v === true || v === 'true' || v === '1' || v === 1 || v === 'on' || v === 'yes'; | |
| if (d.type === 'range') { const n = +v; if (!Number.isFinite(n)) throw new Error(`${k} needs a number`); return Math.min(d.max, Math.max(d.min, n)); } | |
| if (!d.options.includes(String(v))) throw new Error(`${k} must be one of ${d.options.join(', ')}`); | |
| return String(v); | |
| } | |
| function apply() { | |
| const g = window.unrollView?.gfx; | |
| if (g && !base) base = { rot: g.controls.rotateSpeed, zoom: g.controls.zoomSpeed, pan: g.controls.panSpeed, bloom: g.bloom.strength, damp: g.controls.enableDamping }; | |
| if (g) { | |
| const pr = { low: 0.75, medium: Math.min(devicePixelRatio, 1.25), high: Math.min(devicePixelRatio, 2) }[S.quality]; | |
| if (g.renderer.getPixelRatio() !== pr) { g.renderer.setPixelRatio(pr); g.composer?.setPixelRatio?.(pr); g.renderer.setSize(innerWidth, innerHeight); g.composer?.setSize?.(innerWidth, innerHeight); } | |
| g.bloom.enabled = S.quality !== 'low' && S.bloom > 0; | |
| g.bloom.strength = base.bloom * S.bloom; | |
| g.renderer.setClearColor(BG[S.background], 1); | |
| g.controls.rotateSpeed = base.rot * S.sensitivity; g.controls.zoomSpeed = base.zoom * S.sensitivity; g.controls.panSpeed = base.pan * S.sensitivity; | |
| g.controls.enableDamping = S.reducedMotion ? false : base.damp; | |
| } | |
| window.__uiSpeed = S.reducedMotion ? Math.min(S.speed, 1) : S.speed; | |
| const c = document.body.classList; | |
| c.toggle('ui-nobench', !S.inkBench); | |
| c.toggle('ui-reduced', S.reducedMotion); c.toggle('ui-nohud', !S.hud); c.toggle('ui-nolabels', !S.labels); | |
| document.body.dataset.bg = S.background; | |
| units(); | |
| dispatchEvent(new CustomEvent('unroll:settings', { detail: { ...S } })); // feature modules (lib/dragres.js, lib/ct*.js) listen | |
| for (const [k, d] of Object.entries(SETTINGS)) { | |
| const el = $(`set-${k}`); if (!el) continue; | |
| if (d.type === 'bool') el.checked = S[k]; else el.value = S[k]; | |
| const o = $(`set-${k}-out`); if (o) o.textContent = `${S[k]}${d.unit || ''}`; | |
| } | |
| } | |
| function save() { try { localStorage.setItem(STORE, JSON.stringify(S)); } catch {} } | |
| function load() { | |
| let saved = {}; try { saved = JSON.parse(localStorage.getItem(STORE) || '{}'); } catch {} | |
| for (const [k, d] of Object.entries(SETTINGS)) { try { S[k] = k in saved ? coerce(k, saved[k]) : d.def; } catch { S[k] = d.def; } } | |
| // URL parameters win for this visit and are not saved: ?quality=low&speed=2&hud=0 | |
| const q = new URLSearchParams(location.search); | |
| for (const k of Object.keys(SETTINGS)) if (q.has(k)) { try { S[k] = coerce(k, q.get(k)); } catch (e) { console.warn(e.message); } } | |
| } | |
| const settingsApi = { | |
| list: () => Object.entries(SETTINGS).map(([key, d]) => ({ key, ...d, value: S[key] })), | |
| get: (k) => { if (k === undefined) return { ...S }; if (!SETTINGS[k]) throw new Error(`unknown setting ${k}`); return S[k]; }, | |
| set: (k, v) => { if (typeof k === 'object') { for (const [a, b] of Object.entries(k)) S[a] = coerce(a, b); } else S[k] = coerce(k, v); apply(); save(); return { ...S }; }, | |
| reset: () => { for (const [k, d] of Object.entries(SETTINGS)) S[k] = d.def; apply(); save(); return { ...S }; }, | |
| open: () => toggleSettings(true), close: () => toggleSettings(false), | |
| }; | |
| // metric or imperial in the readout (the page itself always measures in mm and m) | |
| let unitBusy = false; | |
| function units() { | |
| for (const id of ['rRad', 'rLen']) { | |
| const el = $(id); if (!el) continue; | |
| if (!el.dataset.uiObs) { el.dataset.uiObs = '1'; new MutationObserver(() => { if (!unitBusy) { el.dataset.metric = el.textContent; units(); } }).observe(el, { childList: true, characterData: true, subtree: true }); el.dataset.metric = el.textContent; } | |
| const m = el.dataset.metric || el.textContent; | |
| const out = S.units === 'imperial' | |
| ? m.replace(/([\d.]+)(\s*\/\s*)([\d.]+)\s*m\b/, (_, a, s, b) => `${(a * 3.2808).toFixed(2)}${s}${(b * 3.2808).toFixed(2)} ft`) | |
| .replace(/([\d.]+)\s*mm\b/, (_, a) => `${(a / 25.4).toFixed(2)} in`).replace(/([\d.]+)\s*m\b/, (_, a) => `${(a * 3.2808).toFixed(2)} ft`) | |
| : m; | |
| if (el.textContent !== out) { unitBusy = true; el.textContent = out; queueMicrotask(() => { unitBusy = false; }); } | |
| } | |
| } | |
| let settingsOpener = null; | |
| function toggleSettings(force) { | |
| const p = $('uiSettings'); if (!p) return false; | |
| const show = force ?? p.hidden; | |
| if (show && p.hidden) { settingsOpener = document.activeElement; p.hidden = false; $('set-quality')?.focus(); } | |
| else if (!show && !p.hidden) { p.hidden = true; settingsOpener?.focus?.(); } | |
| $('btnSettings')?.setAttribute('aria-expanded', !p.hidden); | |
| $('btnSettings')?.setAttribute('aria-pressed', !p.hidden); | |
| return !p.hidden; | |
| } | |
| function buildSettings() { | |
| const p = document.createElement('section'); | |
| p.id = 'uiSettings'; p.className = 'sheet about ui-settings'; p.hidden = true; | |
| p.setAttribute('role', 'dialog'); p.setAttribute('aria-modal', 'false'); p.setAttribute('aria-labelledby', 'uiSetTitle'); | |
| const row = (k, d) => { | |
| if (d.type === 'select') return `<label class="ui-row" for="set-${k}"><span>${d.label}</span><select id="set-${k}" data-setting="${k}" aria-label="${d.label}">${d.options.map((o) => `<option value="${o}">${o[0].toUpperCase() + o.slice(1)}</option>`).join('')}</select></label>`; | |
| if (d.type === 'bool') return `<label class="ui-row ui-check" for="set-${k}"><span>${d.label}</span><input type="checkbox" role="switch" id="set-${k}" data-setting="${k}" aria-label="${d.label}"></label>`; | |
| return `<label class="ui-row" for="set-${k}"><span>${d.label} <output id="set-${k}-out" class="dim"></output></span><input type="range" id="set-${k}" data-setting="${k}" aria-label="${d.label}" min="${d.min}" max="${d.max}" step="${d.step}"></label>`; | |
| }; | |
| const keys = document.querySelector('#help dl.keys'); | |
| p.innerHTML = `<div class="sheet-head"><div><div class="mono dim">PREFERENCES</div><h2 id="uiSetTitle">Settings</h2></div> | |
| <button class="icon" id="uiSetClose" data-action="settings.close" aria-label="Close settings">✕</button></div> | |
| <div class="about-body"><form id="uiSetForm" class="ui-form">${Object.entries(SETTINGS).map(([k, d]) => row(k, d)).join('')}</form> | |
| <h3 class="ui-gh">App</h3><p class="ui-install-row"><button type="button" class="chip" id="uiSetInstall" data-action="install" aria-label="Install Unroll as an app">Install app</button> <span id="uiInstallNote" class="mono dim small" role="status"></span></p> | |
| <p><button type="button" class="chip" id="uiSetReset" data-action="settings.reset" aria-label="Reset settings to defaults">Reset to defaults</button></p> | |
| <p class="mono dim small">Saved in this browser. URL parameters such as <code>?quality=low&speed=2</code> override them for one visit.</p> | |
| <h3 class="ui-gh">Keyboard shortcuts</h3><dl class="keys mono">${keys ? keys.innerHTML : ''}<dt><kbd>S</kbd></dt><dd>Settings</dd></dl></div>`; | |
| document.body.append(p); | |
| $('uiSetClose').onclick = () => toggleSettings(false); | |
| $('uiSetReset').onclick = () => settingsApi.reset(); | |
| $('uiSetInstall').onclick = () => runAction('install'); | |
| pwa.paint(); | |
| p.addEventListener('input', (e) => { const k = e.target.dataset.setting; if (k) settingsApi.set(k, e.target.type === 'checkbox' ? e.target.checked : e.target.value); }); | |
| p.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); toggleSettings(false); } }); | |
| addEventListener('keydown', (e) => { | |
| if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea,select,input:not([type=range]):not([type=checkbox])')) return; | |
| if (e.key === 's' || e.key === 'S') { e.preventDefault(); toggleSettings(); } | |
| if (e.key === 'Escape') toggleSettings(false); | |
| }); | |
| load(); apply(); | |
| } | |
| // ---------------------------------------------------------------- state and URL actions | |
| function state() { | |
| const layer = document.querySelector('.chip[data-layer].on')?.dataset.layer || null; | |
| const tool = document.querySelector('.chip.tool.on')?.dataset.tool || null; | |
| const vis = (id) => !!$(id) && !$(id).hidden; | |
| return { | |
| scroll: $('scrollSel')?.value || null, | |
| layer, layers: [...document.querySelectorAll('.chip[data-layer]')].filter((b) => !b.hidden).map((b) => b.dataset.layer), | |
| position: +(($('scrubIn')?.value || 0) / 1000).toFixed(3), | |
| playing: !!isPlaying(), rollingUp: pressed($('btnRollUp')), | |
| winding: $('rWind')?.textContent || null, | |
| destroy: pressed($('btnTear')), tool: pressed($('btnTear')) ? tool : null, | |
| sandbox: pressed($('btnSandbox')), sound: pressed($('btnSound')), | |
| panels: { settings: vis('uiSettings'), help: vis('help'), about: vis('about'), open: vis('openSheet'), closeup: vis('focus'), bench: !!document.querySelector('.bench:not([hidden]), #bench:not([hidden])') }, | |
| fullscreen: !!document.fullscreenElement, | |
| settings: { ...S }, | |
| actions: Object.keys(ACTIONS), | |
| }; | |
| } | |
| const uiApi = { | |
| settings: settingsApi, | |
| actions: { list: () => Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, control: a.id || a.sel || null, arg: a.toggle ? 'optional boolean' : name === 'seek' ? 'number 0..1' : name === 'scroll' ? 'scroll id' : name === 'play' ? 'optional boolean' : null })), run: runAction }, | |
| action: runAction, | |
| pwa: { state: () => pwa.state(), install: () => pwa.install(), version: () => pwa.version }, | |
| state, stateJSON: () => JSON.stringify(state()), | |
| }; | |
| // window.unroll.ui: the same controls through the page API (every call returns { ok, ... }) | |
| const uiMethods = { | |
| settings: () => ({ settings: { ...S } }), | |
| listSettings: () => ({ settings: settingsApi.list() }), | |
| setSetting: (k, v) => ({ settings: settingsApi.set(k, v) }), | |
| resetSettings: () => ({ settings: settingsApi.reset() }), | |
| settingsPanel: (on) => ({ open: toggleSettings(on) }), | |
| action: (name, arg) => ({ action: name, done: runAction(name, arg) }), | |
| actions: () => ({ actions: uiApi.actions.list() }), | |
| moreControls: (on) => { const f = $('uiFold'); if (!f) return { open: false }; const o = f.getAttribute('aria-expanded') === 'true'; if (on === undefined || !!on !== o) f.click(); return { open: f.getAttribute('aria-expanded') === 'true' }; }, | |
| skipIntro: () => ({ skipped: !!(window.unrollView?.skipIntro?.() ?? false) }), | |
| glossary: (q) => ({ opened: openGlossary(q) }), | |
| glossarySearch: (q) => { const el = $('uiGlossQ'); if (el) { el.value = String(q ?? ''); el.dispatchEvent(new Event('input', { bubbles: true })); } return { terms: glossaryApi.search(q) }; }, | |
| install: async () => ({ outcome: await pwa.install(), state: pwa.state() }), | |
| installState: () => ({ state: pwa.state(), version: pwa.version, offline: !!navigator.serviceWorker?.controller }), | |
| state: () => ({ state: state() }), | |
| }; | |
| const uiSpecs = { | |
| settings: { summary: 'The current Settings values.' }, | |
| listSettings: { summary: 'Every setting with its type, options or range, default and value.' }, | |
| setSetting: { summary: 'Set one setting (key, value) or several ({ key: value }); saved in this browser.', params: [{ name: 'key', type: 'string|object', desc: 'quality, reducedMotion, speed, bloom, background, hud, labels, sensitivity, inkBench, units' }, { name: 'value', type: 'any', desc: 'The value', required: false }] }, | |
| resetSettings: { summary: 'Reset Settings to the defaults.' }, | |
| settingsPanel: { summary: 'Open or close the Settings panel (toggles when omitted).', params: [{ name: 'on', type: 'boolean', desc: 'Open', required: false }] }, | |
| action: { summary: 'Run a control by its stable action name (see ui.actions()).', params: [{ name: 'name', type: 'string', desc: 'Action name' }, { name: 'arg', type: 'any', desc: 'Optional argument', required: false }] }, | |
| actions: { summary: 'Every control action with its label and argument.' }, | |
| moreControls: { summary: 'Small screens: show or hide the View, Destroy and Scrolls groups.', params: [{ name: 'on', type: 'boolean', desc: 'Show', required: false }] }, | |
| skipIntro: { summary: 'Skip the opening animation.' }, | |
| glossary: { summary: 'Open the glossary under About, optionally at a term.', params: [{ name: 'term', type: 'string', desc: 'Term', required: false }] }, | |
| glossarySearch: { summary: 'Filter the glossary by text; returns the matching terms.', params: [{ name: 'query', type: 'string', desc: 'Text' }] }, | |
| install: { summary: 'Install Unroll as an app that works offline (browser prompt, or a new tab when framed); returns the outcome.' }, | |
| installState: { summary: 'Install state (installed, ready, framed, ios, unavailable), the cached version and whether the service worker controls the page.' }, | |
| state: { summary: 'The control panel state: layer, position, Destroy, panels, settings and actions.' }, | |
| }; | |
| // data-testid and data-api on every control this file adds, for tests/coverage.mjs | |
| function tagApi() { | |
| const T = (el, id, api) => { if (el && !el.dataset.testid) { el.dataset.testid = id; el.dataset.api = api; } }; | |
| T($('uiFold'), 'ui-more-controls', 'ui.moreControls()'); | |
| T($('btnSettings'), 'settings', 'ui.settingsPanel()'); | |
| T($('uiReset'), 'camera-reset', 'camera.reset()'); | |
| T($('uiFull'), 'fullscreen-panel', 'fullscreen()'); | |
| T($('uiSetClose'), 'settings-close', 'ui.settingsPanel(false)'); | |
| T($('uiSetReset'), 'settings-reset', 'ui.resetSettings()'); | |
| T($('skipIntro'), 'skip-intro', 'ui.skipIntro()'); | |
| T($('uiInstall'), 'install-app', 'ui.install()'); | |
| T($('uiSetInstall'), 'settings-install-app', 'ui.install()'); | |
| T($('uiGlossOpen'), 'glossary-open', 'ui.glossary()'); | |
| T(document.querySelector('#uiGloss > summary'), 'glossary-toggle', 'ui.glossary()'); | |
| T($('uiGlossQ'), 'glossary-search', 'ui.glossarySearch(query)'); | |
| T($('uiGlossQ')?.closest('label'), 'glossary-search-label', 'ui.glossarySearch(query)'); | |
| T(document.querySelector('#uiBenchAbout button'), 'bench-about', 'bench.open()'); | |
| T(document.querySelector('.ui-bench-link'), 'bench-lede', 'bench.open()'); | |
| for (const k of Object.keys(SETTINGS)) { const el = $(`set-${k}`); T(el, `setting-${k}`, `ui.setSetting('${k}', value)`); T(el?.closest('label'), `setting-${k}-label`, `ui.setSetting('${k}', value)`); } | |
| } | |
| let registered = false; | |
| function expose() { | |
| const v = window.unrollView; if (!v) return false; | |
| Object.assign(v, uiApi); | |
| tagApi(); | |
| const u = window.unroll; | |
| if (!registered && u && typeof u.register === 'function') { registered = true; if (!u.ui) u.register('ui', uiMethods, uiSpecs); } | |
| return true; | |
| } | |
| // URL actions after load: ?layer=gpu&destroy=1&tool=burn&t=0.5&panel=settings | |
| function urlActions() { | |
| const q = new URLSearchParams(location.search); | |
| if (q.has('layer')) runAction(`layer.${q.get('layer')}`); | |
| if (q.get('destroy') === '1' || q.has('tool')) runAction('destroy', true); | |
| if (q.has('tool')) runAction(`tool.${q.get('tool')}`); | |
| if (q.has('pos')) runAction('seek', q.get('pos')); | |
| if (q.get('panel') === 'settings') toggleSettings(true); | |
| if (q.get('play') === '1') runAction('play', true); | |
| } | |
| function skipButton() { | |
| if (!window.unrollView?.introActive?.() || $('skipIntro')) return; | |
| const b = document.createElement('button'); | |
| b.id = 'skipIntro'; b.type = 'button'; b.className = 'chip ui-skip'; b.dataset.action = 'skipIntro'; | |
| b.setAttribute('aria-label', 'Skip intro'); b.setAttribute('aria-keyshortcuts', 'Enter'); b.title = 'Skip the intro (Enter)'; | |
| b.textContent = 'Skip intro'; | |
| b.onclick = () => window.unrollView.skipIntro(); | |
| document.body.append(b); tagApi(); | |
| addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === 'Escape') && $('skipIntro') && !e.target.closest?.('input,select,textarea,button')) window.unrollView.skipIntro(); }); | |
| } | |
| function benchIntro() { | |
| const lede = document.querySelector('.hud.tl .lede'); | |
| if (lede && !lede.querySelector('.ui-bench-link')) lede.insertAdjacentHTML('beforeend', ' <a href="?bench" class="ui-bench-link" data-action="bench">Score your own ink model in the Ink Bench.</a>'); | |
| lede?.querySelector('.ui-bench-link')?.addEventListener('click', (e) => { e.preventDefault(); $('btnBench')?.click(); }); | |
| const ab = document.querySelector('#about .about-body'); | |
| if (ab && !$('uiBenchAbout')) ab.insertAdjacentHTML('afterbegin', '<p id="uiBenchAbout"><b>Ink Bench</b>: compare and score ink detection models against the published ink on a real winding. <button type="button" class="chip" data-action="bench" onclick="document.getElementById(\'btnBench\').click()">Open the Ink Bench</button></p>'); | |
| let seen = false; try { seen = localStorage.getItem('unroll.benchHint') === '1'; localStorage.setItem('unroll.benchHint', '1'); } catch {} | |
| const b = $('btnBench'); | |
| if (!seen && b && S.inkBench) { | |
| const h = document.createElement('div'); h.className = 'ui-hint mono'; h.id = 'uiBenchHint'; h.setAttribute('role', 'status'); | |
| h.textContent = 'New: the Ink Bench scores ink detection models. Try it here.'; | |
| document.body.append(h); | |
| const r = b.getBoundingClientRect(); | |
| h.style.top = `${Math.max(8, innerWidth > 720 ? r.top : r.top - 52)}px`; | |
| h.style[innerWidth > 720 ? 'right' : 'left'] = innerWidth > 720 ? `${innerWidth - r.left + 10}px` : `${r.left}px`; | |
| setTimeout(() => h.remove(), 7000); | |
| } | |
| } | |
| function ready() { | |
| if ($('btnHelp') && window.unrollView && $('btnTear') && (!$('loader') || $('loader').classList.contains('gone'))) { build(); expose(); skipButton(); benchIntro(); tagApi(); urlActions(); setInterval(expose, 2000); } | |
| else setTimeout(ready, 60); | |
| } | |
| setTimeout(ready, 0); // after the whole module has run, so pwa (defined below) exists | |
| // ---------------------------------------------------------------- glossary (glossary.json), under About | |
| let GLOSS = []; | |
| const glossaryApi = { | |
| list: () => GLOSS.map((t) => ({ ...t })), | |
| get: (q) => { const s = String(q).toLowerCase(); return GLOSS.find((t) => t.id === s || t.term.toLowerCase() === s || t.aliases.some((a) => a.toLowerCase() === s)) || null; }, | |
| search: (q) => { const s = String(q || '').toLowerCase(); return GLOSS.filter((t) => (t.term + ' ' + t.aliases.join(' ') + ' ' + t.def).toLowerCase().includes(s)); }, | |
| open: (q) => openGlossary(q), | |
| json: () => JSON.stringify(GLOSS), | |
| }; | |
| uiApi.glossary = glossaryApi; | |
| (ACTIONS.glossary = { label: 'Open the glossary, optionally at a term', run: (q) => openGlossary(q) }); | |
| const escH = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); | |
| function openGlossary(q) { | |
| const ab = $('about'); if (!ab) return false; | |
| if (ab.hidden) $('btnInfo')?.click(); | |
| const g = $('uiGloss'); if (!g) return false; | |
| g.open = true; | |
| const t = q ? glossaryApi.get(q) : null; | |
| const el = t ? $(`g-${t.id}`) : $('uiGlossQ'); | |
| el?.scrollIntoView({ block: 'center' }); | |
| if (t) { el.classList.add('ui-flash'); setTimeout(() => el.classList.remove('ui-flash'), 1600); el.focus(); } else el?.focus(); | |
| return t ? t.id : true; | |
| } | |
| function buildGlossary() { | |
| const body = document.querySelector('#about .about-body'); if (!body || $('uiGloss')) return; | |
| const d = document.createElement('details'); d.id = 'uiGloss'; d.className = 'ui-gloss'; | |
| d.innerHTML = `<summary><h3 class="ui-gh">Glossary</h3></summary> | |
| <label class="ui-row" for="uiGlossQ"><span class="dim">Search the glossary</span><input type="search" id="uiGlossQ" data-action="glossary.search" aria-label="Search the glossary" placeholder="patch, voxel, winding..."></label> | |
| <dl class="ui-gl" id="uiGlossList">${GLOSS.map((t) => `<dt id="g-${t.id}" tabindex="-1">${escH(t.term)}</dt><dd>${escH(t.def)}</dd>`).join('')}</dl> | |
| <p class="mono dim small">Also at <a href="glossary.json" target="_blank" rel="noopener">glossary.json</a> and window.unrollView.glossary.</p>`; | |
| body.prepend(d); | |
| $('uiGlossQ').addEventListener('input', (e) => { | |
| const hit = new Set(glossaryApi.search(e.target.value).map((t) => t.id)); | |
| for (const dt of d.querySelectorAll('dt')) { const on = hit.has(dt.id.slice(2)); dt.hidden = !on; dt.nextElementSibling.hidden = !on; } | |
| }); | |
| const btn = document.createElement('p'); | |
| btn.innerHTML = '<button type="button" class="chip" id="uiGlossOpen" data-action="glossary" aria-label="Open the glossary">Glossary of terms</button>'; | |
| body.prepend(btn); $('uiGlossOpen').onclick = () => openGlossary(); | |
| tagApi(); | |
| } | |
| // dotted underline links from jargon in the page text to its glossary entry (every occurrence) | |
| function linkTerms(root, done = new Set()) { | |
| if (!root || !GLOSS.length) return; | |
| const pairs = GLOSS.flatMap((t) => [t.term, ...t.aliases].map((a) => [a, t])).filter(([a]) => a.length > 2).sort((a, b) => b[0].length - a[0].length); | |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (n) => (n.parentElement.closest('a,button,select,option,kbd,code,summary,.ui-gloss,input,label') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT) }); | |
| const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); | |
| for (const n of nodes) { | |
| for (const [a, t] of pairs) { | |
| const re = new RegExp(`(^|[^\\w])(${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![\\w])`, 'i'); | |
| const m = re.exec(n.data); if (!m) continue; | |
| const at = m.index + m[1].length; | |
| const rest = n.splitText(at); const after = rest.splitText(m[2].length); | |
| const link = document.createElement('a'); | |
| link.className = 'ui-term'; link.href = `#g-${t.id}`; link.dataset.term = t.id; link.title = `${t.term}: ${t.def}`; link.textContent = rest.data; | |
| rest.replaceWith(link); | |
| return linkTerms(root, done); | |
| } | |
| } | |
| } | |
| function glossaryLinks() { | |
| for (const sel of ['#about .about-body > :not(.ui-gloss)', '#layerWhy p', '.hud.tl .lede', '#help .help-foot']) for (const el of document.querySelectorAll(sel)) linkTerms(el); | |
| const hint = $('layerHint'); | |
| if (hint) { let busy = false; const go = () => { if (busy) return; busy = true; linkTerms(hint); busy = false; }; new MutationObserver(() => queueMicrotask(go)).observe(hint, { childList: true }); go(); } | |
| document.addEventListener('click', (e) => { const a = e.target.closest?.('a.ui-term'); if (a) { e.preventDefault(); openGlossary(a.dataset.term); } }); | |
| } | |
| fetch('glossary.json?v=20261027one1').then((r) => r.json()).then((j) => { | |
| GLOSS = j.terms.slice().sort((a, b) => a.term.localeCompare(b.term)); | |
| const go = () => { if (!$('about') || !document.body.classList.contains('ui2')) return setTimeout(go, 200); buildGlossary(); glossaryLinks(); expose(); }; | |
| go(); | |
| }).catch((e) => console.warn('glossary', e)); | |
| // ---------------------------------------------------------------- install as an app (PWA) | |
| // sw.js is registered with the version and build sha from version.json in its URL, so each build gets its own caches. | |
| const pwa = { | |
| prompt: null, version: null, reg: null, | |
| standalone: () => matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches || navigator.standalone === true, | |
| framed: () => { try { return window.top !== window; } catch { return true; } }, | |
| ios: () => /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1), | |
| state() { | |
| if (this.standalone()) return 'installed'; | |
| if (this.prompt) return 'ready'; | |
| if (this.framed()) return 'framed'; | |
| if (this.ios()) return 'ios'; | |
| return 'unavailable'; | |
| }, | |
| canOffer() { return this.state() !== 'installed'; }, | |
| note() { | |
| return { installed: 'Running as an installed app. The default scroll works offline.', | |
| ready: 'Adds Unroll to your apps. The default scroll then works offline.', | |
| framed: 'Opens the app page in a new tab, where your browser can install it.', | |
| ios: 'In Safari: Share, then Add to Home Screen.', | |
| unavailable: 'Use your browser menu: Install app or Add to Home Screen.' }[this.state()]; | |
| }, | |
| paint() { | |
| const st = this.state(); | |
| for (const id of ['uiInstall', 'uiSetInstall']) { const b = $(id); if (!b) continue; b.hidden = st === 'installed' && id === 'uiInstall'; b.disabled = st === 'installed'; b.title = this.note(); } | |
| const n = $('uiInstallNote'); if (n) n.textContent = this.note() + (this.version ? ` Version ${this.version}.` : ''); | |
| }, | |
| async install() { | |
| const st = this.state(); | |
| if (st === 'installed') return 'installed'; | |
| if (st === 'ready') { | |
| const p = this.prompt; this.prompt = null; | |
| p.prompt(); const r = await p.userChoice.catch(() => ({ outcome: 'dismissed' })); | |
| this.paint(); return r.outcome; | |
| } | |
| if (st === 'framed') { window.open(location.href.split('#')[0], '_blank', 'noopener'); return 'opened'; } | |
| toggleSettings(true); this.paint(); $('uiInstallNote')?.focus?.(); | |
| return st; | |
| }, | |
| }; | |
| addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); pwa.prompt = e; pwa.paint(); }); | |
| addEventListener('appinstalled', () => { pwa.prompt = null; pwa.paint(); }); | |
| if ('serviceWorker' in navigator && isSecureContext) { | |
| addEventListener('load', () => { | |
| fetch('version.json', { cache: 'no-store' }).then((r) => r.json()).then((v) => { | |
| pwa.version = v.version; pwa.paint(); | |
| const tag = v.sha ? `${v.version}-${v.sha}` : v.version; | |
| return navigator.serviceWorker.register(`sw.js?v=${encodeURIComponent(tag)}`, { scope: './' }); | |
| }).then((reg) => { pwa.reg = reg; }).catch((e) => console.warn('service worker', e)); | |
| }); | |
| } | |