File size: 16,590 Bytes
cc6b6d8 08a393b cc6b6d8 757768f cc6b6d8 757768f cc6b6d8 757768f cc6b6d8 757768f 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 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | // The public page API (window.unroll): one table that drives argument checking in the page,
// the generated api.json, and the docs. Pure data, no DOM. Version it with API_VERSION.
export const API_VERSION = '1.0.0';
const num = (name, desc, extra = {}) => ({ name, type: 'number', desc, ...extra });
const str = (name, desc, extra = {}) => ({ name, type: 'string', desc, ...extra });
const bool = (name, desc, extra = {}) => ({ name, type: 'boolean', desc, ...extra });
const obj = (name, desc, extra = {}) => ({ name, type: 'object', desc, ...extra });
const XY = [num('x', 'Screen x in CSS pixels from the left of the page', { min: 0 }), num('y', 'Screen y in CSS pixels from the top', { min: 0 })];
const HOLD = obj('options', 'Optional { hold }: how long to press, in ms (0 to 5000)', { required: false });
const DRAG = obj('options', 'Optional { dx, dy, steps, ms }: drag vector in CSS px (default 90, -200), number of pointer moves (default 24), time per move in ms (default 16)', { required: false });
// every method returns a Promise of { ok: true, ... } or { ok: false, error, code }
export const METHODS = {
version: { group: 'meta', summary: 'The app version from version.json: { version (semver), date, sha, api }. Also in api.json and Bench export bundles.', params: [] },
help: { group: 'meta', summary: 'A short guide: how calls work, a quick start and the methods by group. help("destroy") narrows to a group, help("setLayer") to one method.', params: [str('topic', 'A method or group name', { required: false })] },
schema: { group: 'meta', summary: 'This API: version, every method with its parameters, events, URL parameters and keyboard actions.', params: [] },
getState: { group: 'meta', summary: 'The whole page state as JSON: scroll, unroll position, layer, winding, close-up, panels, Destroy mode, camera, readout and a text description of the view.', params: [] },
describe: { group: 'meta', summary: 'A plain-text description of what is on screen now (the same text screen readers get).', params: [] },
links: { group: 'meta', summary: 'Every link on the page (credits, licenses, docs) with its text and URL.', params: [] },
deepLink: { group: 'meta', summary: 'A URL that restores the current state (scroll, t, layer, focus, compare, panel, Destroy mode; camera too if asked).', params: [bool('camera', 'Include the camera position (cam=...)', { required: false })] },
on: { group: 'meta', summary: 'Subscribe to an event; the callback gets a JSON payload. Returns { ok, id } for off().', params: [str('event', 'Event name', { enum: ['change', 'unroll', 'layer', 'focus', 'panel', 'destroy', 'camera', 'settled', 'scroll', 'extension', 'error'] }), { name: 'callback', type: 'function', desc: 'Called with (payload)' }] },
off: { group: 'meta', summary: 'Remove a subscription made with on().', params: [num('id', 'The id on() returned')] },
run: { group: 'meta', summary: 'Run a batch of calls in order. Each step is ["method", ...args] or { "method", "args" }; dotted names reach groups (e.g. "camera.orbit"). Stops at the first failure unless options.continueOnError.', params: [{ name: 'steps', type: 'array', desc: 'The calls' }, obj('options', '{ continueOnError: boolean }', { required: false })] },
waitFor: { group: 'meta', summary: 'Wait until the state matches. condition is "ready", "settled" (unroll and camera at rest), "overlayLoaded", or an object of dotted state paths to values or to { eq, ne, gte, lte, gt, lt, in }.', params: [{ name: 'condition', type: 'string|object', desc: 'What to wait for' }, obj('options', '{ timeout: ms (default 10000) }', { required: false })] },
reload: { group: 'meta', summary: 'Reload the page (keeps the URL state).', params: [] },
listScrolls: { group: 'scrolls', summary: 'Scrolls in the registry and scrolls opened in this browser (local).', params: [] },
openScroll: { group: 'scrolls', summary: 'Open a scroll by registry id or local key. The page reloads; call waitFor("ready") after the new page loads.', params: [str('id', 'Registry id or local key (local-...)')] },
setUnroll: { group: 'view', summary: 'Set the unroll position, 0 rolled to 1 fully unrolled. Animates unless instant.', params: [num('t', 'Position', { min: 0, max: 1 }), obj('options', '{ instant: boolean }', { required: false })] },
play: { group: 'view', summary: 'Start or stop the automatic unroll (toggles if omitted).', params: [bool('on', 'Play or pause', { required: false })] },
setLayer: { group: 'view', summary: 'Show a layer: "xray", "ink" or "overlay" (aliases "ct", "gpu", or the manifest layer id).', params: [str('id', 'Layer')] },
focus: { group: 'view', summary: 'Open the close-up of a winding (current winding if omitted), or close it with null. options.view picks the tab: "main", "overlay" or "compare".', params: [{ name: 'winding', type: 'string|number|null', desc: 'Winding id such as "w059" (or its number, 59), null to close', required: false }, obj('options', '{ view: "main" | "overlay" | "compare" }', { required: false })] },
compare: { group: 'view', summary: 'Compare swipe in the close-up: on/off and the split position 0..1 (0 shows only the overlay, 1 only the main ink). Opens the close-up on the current winding if needed.', params: [bool('on', 'Show the compare view'), num('pos', 'Split position', { min: 0, max: 1, required: false })] },
closeup: { group: 'view', summary: 'Zoom and pan the close-up image: { scale } multiplies the zoom, { x, y } pans in CSS px, { fit: true } fits it.', params: [obj('options', '{ scale, x, y, fit }')] },
panel: { group: 'view', summary: 'Open or close a panel: "about", "help", "open" (Open your own), "why" (why two ink layers), "embed" (the iframe snippet). Toggles if open is omitted.', params: [str('name', 'Panel', { enum: ['about', 'help', 'open', 'why', 'embed'] }), bool('open', 'Open or close', { required: false })] },
sound: { group: 'view', summary: 'Crackle sound for Destroy on or off (browsers need a user gesture first to play audio).', params: [bool('on', 'Sound on')] },
fullscreen: { group: 'view', summary: 'Enter or leave full screen (browsers may refuse without a user gesture).', params: [bool('on', 'Full screen', { required: false })] },
screenshot: { group: 'view', summary: 'A PNG of the view as a data URL. options.clean leaves out the title overlay; options.download also saves it like the camera button.', params: [obj('options', '{ clean: boolean, download: boolean }', { required: false })] },
key: { group: 'view', summary: 'Press a keyboard shortcut, as a person would (see schema().keys).', params: [str('key', 'Key name, e.g. "t", "ArrowRight", "?"'), obj('options', '{ shift: boolean }', { required: false })] },
reset: { group: 'view', summary: 'Back to the start: close panels, leave Destroy, put every sheet back, roll up, reset the camera and layer.', params: [] },
'camera.get': { group: 'camera', summary: 'Camera position, target, distance, azimuth and polar angle (degrees), field of view.', params: [] },
'camera.orbit': { group: 'camera', summary: 'Orbit around the target by degrees.', params: [num('dAzimuth', 'Degrees around the vertical axis'), num('dPolar', 'Degrees up or down', { required: false })] },
'camera.zoom': { group: 'camera', summary: 'Zoom by a factor (2 is twice as close, 0.5 twice as far).', params: [num('factor', 'Zoom factor', { min: 0.01, max: 100 })] },
'camera.pan': { group: 'camera', summary: 'Pan the view in millimeters along the screen (x right, y up).', params: [num('dx', 'mm right'), num('dy', 'mm up', { required: false })] },
'camera.set': { group: 'camera', summary: 'Put the camera at a position looking at a target, both [x, y, z] in scene millimeters.', params: [{ name: 'position', type: 'array', desc: '[x, y, z]' }, { name: 'target', type: 'array', desc: '[x, y, z]', required: false }] },
'camera.reset': { group: 'camera', summary: 'Fly back to the default view for the current position (the V key).', params: [] },
'destroy.mode': { group: 'destroy', summary: 'Enter or leave Destroy mode (damage the scroll as it is, rolled up or unrolled).', params: [bool('on', 'Destroy mode on', { required: false })] },
'destroy.tool': { group: 'destroy', summary: 'Pick the Destroy tool used by clicks and drags: "tear", "crush", "burn" or "water".', params: [str('name', 'Tool', { enum: ['tear', 'crush', 'burn', 'water'] })] },
'destroy.aim': { group: 'destroy', summary: 'A screen point that is on the papyrus right now, nearest the center of the view: { x, y, winding }. Use it for the tool calls.', params: [] },
'destroy.tear': { group: 'destroy', summary: 'Rip the papyrus with a drag from (x, y). Enters Destroy mode and picks Tear. Returns damage stats before and after.', params: [...XY, DRAG] },
'destroy.crush': { group: 'destroy', summary: 'Press at (x, y) to crumble the papyrus; options.hold (ms, default 700) presses harder and deeper.', params: [...XY, HOLD] },
'destroy.burn': { group: 'destroy', summary: 'Ignite the papyrus at (x, y); embers then creep along the fibers.', params: [...XY, HOLD] },
'destroy.water': { group: 'destroy', summary: 'Pour water at (x, y); options.hold (ms, default 900) widens the wet patch.', params: [...XY, HOLD] },
'destroy.reset': { group: 'destroy', summary: 'Undo all damage, clear the debris and put the Sandbox sheets back.', params: [] },
'destroy.sandbox': { group: 'destroy', summary: 'Sandbox on or off: the strip laid flat with full fracture physics (Destroy mode only).', params: [bool('on', 'Sandbox on', { required: false })] },
'destroy.grab': { group: 'destroy', summary: 'In the Sandbox, grab the papyrus at (x, y) and pull (crushes instead while the Crush tool is picked). Returns breaks before and after.', params: [...XY, DRAG] },
'destroy.crumble': { group: 'destroy', summary: 'Crumble the whole scroll: it breaks up from the edges into a heap of dust. destroy.reset() restores it.', params: [] },
prizeOnly: { group: 'scrolls', summary: 'Show only prize eligible scrolls in the scroll picker, on or off (toggles when omitted). Returns { prizeOnly, visible }.', params: [bool('on', 'Only prize eligible scrolls')] },
'destroy.shake': { group: 'destroy', summary: 'Sandbox only: knock the table, loosening random bits.', params: [] },
'destroy.stats': { group: 'destroy', summary: 'Damage numbers (masks, embers, particles, shards, puddles) and Sandbox numbers (nodes, links, breaks, debris).', params: [] },
'destroy.tools': { group: 'destroy', summary: 'The Destroy tools in this build.', params: [] },
rollUp: { group: 'view', summary: 'Roll the scroll back up with a steady rewind (the Roll up button); false stops it. Toggles if omitted.', params: [bool('on', 'Rolling', { required: false })] },
'embed.snippet': { group: 'embed', summary: 'The iframe snippet for this viewer: { url, html }. options: { state (start at the current view, default true), autoplay, controls (default true), width, height }.', params: [obj('options', '{ state, autoplay, controls, width, height }', { required: false })] },
'embed.info': { group: 'embed', summary: 'Embed mode: { embedded (?embed=1), framed (inside an iframe), active (the scene has the pointer and wheel), controls, autoplay }.', params: [] },
'embed.activate': { group: 'embed', summary: 'In embed mode, hand the pointer, touch and wheel to the scene (like clicking the "Click to interact" layer), or give them back with false.', params: [bool('on', 'Activate', { required: false })] },
'open.fromURL': { group: 'open', summary: 'Open your own: fetch a manifest.json or zip from a URL (the server must allow CORS). Nothing is uploaded. On success the page reloads into the new scroll.', params: [str('url', 'http(s) URL')] },
'open.fromFiles': { group: 'open', summary: 'Open your own from file contents: [{ name, data }] where data is an ArrayBuffer, a Uint8Array, a Blob or a base64 string. A zip, or a manifest.json plus its files. On success the page reloads.', params: [{ name: 'files', type: 'array', desc: 'Files' }] },
'open.example': { group: 'open', summary: 'Build the synthetic example package. Returns it as base64 (options.download also saves the zip).', params: [obj('options', '{ download: boolean }', { required: false })] },
'open.listLocal': { group: 'open', summary: 'Scrolls kept in this browser.', params: [] },
'open.removeLocal': { group: 'open', summary: 'Remove a scroll kept in this browser.', params: [str('key', 'Local key')] },
'open.validate': { group: 'open', summary: 'Check a manifest object with the page validator, without opening anything.', params: [obj('manifest', 'The manifest')] },
};
export const EVENTS = {
change: 'Any state change: { changed: [paths], state }',
unroll: 'The unroll position settled: { t }',
layer: 'The layer changed: { layer }',
focus: 'The close-up opened, closed or changed tab: { focus }',
panel: 'A panel opened or closed: { panels }',
destroy: 'Destroy mode, crush or a tool was used: { destroy }',
camera: 'The camera came to rest somewhere new: { camera }',
settled: 'Unroll and camera at rest: { state }',
scroll: 'A scroll is about to open (the page reloads): { id }',
extension: 'An add-on registered a method group, for example bench: { group, methods }',
error: 'A call failed: { method, error }',
};
export const URL_PARAMS = {
scroll: 'Registry id or local key (alias s)',
t: 'Unroll position 0..1',
layer: 'xray, ink or overlay (alias l; gpu and ct also work)',
focus: 'Winding id: open its close-up',
view: 'Close-up tab: main, overlay or compare',
compare: 'Open the compare swipe at this split position 0..1 (on the focus winding, or the current one)',
panel: 'about, help, open, why or embed',
embed: '1 for the compact embed layout with click to interact (query string only)',
autoplay: '1 to play the unroll on load (embed mode)',
controls: '0 to hide the controls (embed mode)',
destroy: '1 to start in Destroy mode',
tool: 'Destroy tool: tear, crush, burn or water (implies destroy=1)',
crush: '1: the same as tool=crush',
sandbox: '1 to start in the Destroy Sandbox',
cam: 'Camera px,py,pz,tx,ty,tz in scene millimeters',
shot: 'In the query string: a clean frame for share images',
};
export const KEYS = {
Space: 'play', ArrowRight: 'setUnroll(t + 0.01)', ArrowLeft: 'setUnroll(t - 0.01)', 'Shift+Arrow': 'setUnroll(t +/- 0.05)',
Home: 'setUnroll(0)', End: 'setUnroll(1)', 1: 'setLayer("xray")', 2: 'setLayer("ink")', 3: 'setLayer("overlay")',
r: 'focus()', v: 'camera.reset()', t: 'destroy.mode()', x: 'destroy.tool("tear")', c: 'destroy.tool("crush")', b: 'destroy.tool("burn")', w: 'destroy.tool("water")', u: 'rollUp()', f: 'fullscreen()', i: 'panel("about")', '?': 'panel("help")', Escape: 'close panels',
'canvas focused: arrows': 'camera.orbit', 'canvas focused: + and -': 'camera.zoom', 'canvas focused: Shift+arrows': 'camera.pan', 'canvas focused: Enter': 'focus()',
'close-up focused: + - arrows 0': 'closeup()',
};
// argument checking, shared by every method
export function checkArgs(name, args) {
const spec = METHODS[name];
if (!spec) return `Unknown method "${name}". Call unroll.schema() for the list.`;
const ps = spec.params;
if (args.length > ps.length) return `${name} takes at most ${ps.length} argument${ps.length === 1 ? '' : 's'} (${ps.map((p) => p.name).join(', ') || 'none'}); got ${args.length}.`;
for (const [i, p] of ps.entries()) {
const v = args[i];
if (v === undefined) { if (p.required !== false) return `${name}: "${p.name}" is required (${p.type}: ${p.desc}).`; continue; }
const types = p.type.split('|');
const ok = types.some((t) => (t === 'null' ? v === null : t === 'array' ? Array.isArray(v) : t === 'object' ? v && typeof v === 'object' && !Array.isArray(v) : typeof v === t));
if (!ok) return `${name}: "${p.name}" must be ${p.type}; got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}.`;
if (p.type === 'number' || (types.includes('number') && typeof v === 'number')) {
if (!Number.isFinite(v)) return `${name}: "${p.name}" must be a finite number.`;
if (p.min !== undefined && v < p.min) return `${name}: "${p.name}" must be at least ${p.min}; got ${v}.`;
if (p.max !== undefined && v > p.max) return `${name}: "${p.name}" must be at most ${p.max}; got ${v}.`;
}
if (p.enum && !p.enum.includes(v)) return `${name}: "${p.name}" must be one of ${p.enum.map((x) => JSON.stringify(x)).join(', ')}; got ${JSON.stringify(v)}.`;
}
return null;
}
|