unroll / lib /openown.js
SharpMeow's picture
Public API at window.unroll (schema, help, coverage test; ui and bench groups), embed mode with postMessage bridge and oEmbed, version 0.9.4 in About and API, ink_9um (9 µm) label and credit, Ink Bench hardening from the audit, hashchange without reload, capped test hooks
cc6b6d8
Raw History Blame Contribute Delete
10.1 kB
// "Open your own": read a scroll from files, a zip or a URL, entirely in this browser.
// Nothing is uploaded: files are read with the File API, checked, and kept in IndexedDB on this device.
import { validateManifest, LOCAL_LIMITS, ext, MESH_EXT, IMAGE_EXT } from './manifest.js';
import { readZip } from './formats.js';
import { findManifest, fileSource, buildMeshScroll, saveLocal, deleteLocal, listLocal } from './scrolls.js';
import { exampleZip } from './example.js';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const MB = (n) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`;
const OK_EXT = ['json', 'zip', 'txt', 'md', ...MESH_EXT, ...IMAGE_EXT];
// files from a drop, a file picker or a zip: [{ name, blob }]
export async function gatherFiles(list, lim = LOCAL_LIMITS) {
const out = [];
for (const f of list) {
const name = f.relPath || f.webkitRelativePath || f.name;
const e = ext(name);
if (e === 'zip') {
if (f.size > lim.maxZipBytes) throw new Error(`${f.name} is ${MB(f.size)}; the limit for a zip is ${MB(lim.maxZipBytes)}.`);
out.push(...(await readZip(await f.arrayBuffer(), lim)));
} else if (OK_EXT.includes(e)) {
if (f.size > lim.maxFileBytes) throw new Error(`${name} is ${MB(f.size)}; the limit per file is ${MB(lim.maxFileBytes)}.`);
out.push({ name, blob: f });
}
}
if (!out.length) throw new Error('None of these files can be used. Open a manifest.json with its OBJ or PLY meshes and PNG, JPG or TIFF images, or a zip of them.');
const total = out.reduce((a, f) => a + f.blob.size, 0);
if (total > lim.maxTotalBytes) throw new Error(`These files add up to ${MB(total)}; the limit is ${MB(lim.maxTotalBytes)}.`);
return out.filter((f) => !/(^|\/)(__MACOSX|\.)/.test(f.name));
}
// a manifest or zip at a URL; the manifest's files are fetched from beside it. Needs CORS on that server.
export async function filesFromURL(url, lim = LOCAL_LIMITS, onStep = () => {}) {
let u;
try { u = new URL(url, location.href); } catch (e) { throw new Error('That is not a valid URL.'); }
if (!/^https?:$/.test(u.protocol)) throw new Error('Only http and https URLs can be opened.');
const get = async (href, max) => {
let r;
try { r = await fetch(href, { mode: 'cors', credentials: 'omit' }); }
catch (e) { throw new Error(`Could not fetch ${href}. The server may not allow cross-origin requests (CORS), or it is unreachable. Download the files and drop them here instead.`); }
if (!r.ok) throw new Error(`${href}: the server answered ${r.status}.`);
const len = +r.headers.get('content-length');
if (len && len > max) throw new Error(`${href} is ${MB(len)}; the limit is ${MB(max)}.`);
const b = await r.blob();
if (b.size > max) throw new Error(`${href} is ${MB(b.size)}; the limit is ${MB(max)}.`);
return b;
};
onStep(`Fetching ${u.href}`);
const first = await get(u.href, lim.maxZipBytes);
const head = new Uint8Array(await first.slice(0, 4).arrayBuffer());
if (ext(u.pathname) === 'zip' || (head[0] === 0x50 && head[1] === 0x4b)) return readZip(await first.arrayBuffer(), lim);
let m;
try { m = JSON.parse(await first.text()); } catch (e) { throw new Error('The URL is neither a zip nor a JSON manifest.'); }
const v = validateManifest(m, { limits: lim });
if (!v.ok) throw Object.assign(new Error('The manifest has problems.'), { list: v.errors });
const files = [{ name: 'manifest.json', blob: first }];
let total = first.size;
for (const p of v.files) {
onStep(`Fetching ${p}`);
const b = await get(new URL(p, u).href, lim.maxFileBytes);
total += b.size;
if (total > lim.maxTotalBytes) throw new Error(`The files add up to more than ${MB(lim.maxTotalBytes)}.`);
files.push({ name: p, blob: b });
}
return files;
}
// check everything a scroll needs, parse it all once, and keep it in this browser
export async function checkAndStore(files, onStep = () => {}) {
const { path, manifest } = await findManifest(files);
const src = fileSource(files, path);
const v = validateManifest(manifest, { limits: LOCAL_LIMITS, fileSize: (p) => src.size(p) });
if (!v.ok) throw Object.assign(new Error(`${path} has ${v.errors.length} problem${v.errors.length > 1 ? 's' : ''}.`), { list: v.errors, warnings: v.warnings });
onStep('Reading meshes and images');
await buildMeshScroll(manifest, src, { limits: LOCAL_LIMITS, atlasSide: 1024 }); // a full trial build, small atlas
const keep = v.files.concat(path).map((p) => src.find(p) || { name: p }).filter((f) => f.blob);
const key = `local-${manifest.id}-${Date.now().toString(36)}`;
const size = keep.reduce((a, f) => a + f.blob.size, 0);
// opening the same scroll again replaces the copy kept before
for (const old of await listLocal()) if (old.id === manifest.id) await deleteLocal(old.key).catch(() => {});
try { await saveLocal({ key, id: manifest.id, name: manifest.name, added: Date.now(), size, files: keep.map((f) => ({ name: f.name, blob: f.blob })) }); }
catch (e) { throw new Error(`This browser would not store the scroll (${e.message || e}). Private browsing or a full disk can cause this.`); }
return { key, warnings: v.warnings };
}
export function setupOpenOwn({ $, onOpen, currentKey }) {
const sheet = $('openSheet'), drop = $('oDrop'), status = $('oStatus');
const show = (html, cls = '') => { status.className = `o-status mono ${cls}`; status.innerHTML = html; status.hidden = !html; };
const fail = (e) => {
const list = e.list ? `<ul>${e.list.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : '';
show(`<b>Could not open it.</b> ${esc(e.message || e)}${list}`, 'err');
};
async function run(get) {
show('Reading files...', 'busy');
try {
const files = await get();
const { key, warnings } = await checkAndStore(files, (s) => show(esc(s), 'busy'));
show(`Loaded. ${warnings.length ? `${warnings.length} note${warnings.length > 1 ? 's' : ''}: ${esc(warnings.join(' '))}` : ''} Opening...`, 'ok');
setTimeout(() => onOpen(key), 300);
} catch (e) { console.warn(e); fail(e); }
}
// drag and drop, including folders
const readEntry = (entry, path = '') => new Promise((ok) => {
if (entry.isFile) entry.file((f) => { f.relPath = path + f.name; ok([f]); }, () => ok([]));
else if (entry.isDirectory) {
const r = entry.createReader(), all = [];
const more = () => r.readEntries(async (es) => {
if (!es.length) { ok((await Promise.all(all)).flat()); return; }
for (const e of es) all.push(readEntry(e, `${path}${entry.name}/`));
more();
}, () => ok([]));
more();
} else ok([]);
});
for (const t of [drop, sheet]) {
t.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
t.addEventListener('dragleave', () => drop.classList.remove('over'));
t.addEventListener('drop', (e) => {
e.preventDefault(); drop.classList.remove('over');
const items = [...(e.dataTransfer.items || [])].map((i) => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean);
run(async () => gatherFiles(items.length ? (await Promise.all(items.map((it) => readEntry(it)))).flat() : [...e.dataTransfer.files]));
});
}
// pages can be dropped on anywhere: open the sheet when files are dragged over the 3D view
addEventListener('dragenter', (e) => { if (e.dataTransfer && [...e.dataTransfer.types].includes('Files') && sheet.hidden) open(); });
$('oPick').onchange = (e) => { const fl = [...e.target.files]; e.target.value = ''; run(() => gatherFiles(fl)); };
$('oPickDir').onchange = (e) => { const fl = [...e.target.files]; e.target.value = ''; run(() => gatherFiles(fl)); };
drop.onclick = (e) => { if (e.target === drop || e.target.closest('.o-drop-text')) $('oPick').click(); };
drop.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('oPick').click(); } };
$('oURLForm').onsubmit = (e) => {
e.preventDefault();
const u = $('oURL').value.trim();
if (!u) { show('Paste the URL of a manifest.json or a zip first.', 'err'); return; }
run(() => filesFromURL(u, LOCAL_LIMITS, (s) => show(esc(s), 'busy')));
};
$('oExample').onclick = async () => {
$('oExample').disabled = true;
try {
const b = await exampleZip();
const a = document.createElement('a');
a.href = URL.createObjectURL(b); a.download = 'unroll-synthetic-scroll.zip'; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
} finally { $('oExample').disabled = false; }
};
$('oLimits').textContent = `Limits: ${MB(LOCAL_LIMITS.maxFileBytes)} per file, ${MB(LOCAL_LIMITS.maxTotalBytes)} in all, ${LOCAL_LIMITS.maxWindings} windings, ${(LOCAL_LIMITS.maxVertices / 1e6).toFixed(0)} million vertices, images up to ${LOCAL_LIMITS.maxTextureSide} px on a side.`;
async function refreshList() {
const L = await listLocal();
const box = $('oSaved');
box.hidden = !L.length;
$('oSavedList').innerHTML = L.map((s) => `<li><span>${esc(s.name)} <span class="dim">${esc(s.id)} · ${MB(s.size || 0)}${s.key === currentKey ? ' · open now' : ''}</span></span>` +
`<span><button class="chip" data-open="${esc(s.key)}" aria-label="Open ${esc(s.name)}" data-testid="open-saved-open-${esc(s.key)}" data-api="openScroll('${esc(s.key)}')">Open</button><button class="chip" data-del="${esc(s.key)}" aria-label="Remove ${esc(s.name)} from this browser" data-testid="open-saved-remove-${esc(s.key)}" data-api="open.removeLocal('${esc(s.key)}')">Remove</button></span></li>`).join('');
for (const b of box.querySelectorAll('[data-open]')) b.onclick = () => onOpen(b.dataset.open);
for (const b of box.querySelectorAll('[data-del]')) b.onclick = async () => { await deleteLocal(b.dataset.del); if (b.dataset.del === currentKey) onOpen(null); else refreshList(); };
}
function open() { sheet.hidden = false; refreshList(); $('oClose').focus(); }
$('oClose').onclick = () => { sheet.hidden = true; };
return { open, refreshList, run };
}