Search / static /reader-runtime.js
OpenCode
Complete Reader recovery and static asset updates
5084a8e
Raw History Blame Contribute Delete
6.62 kB
(function (root) {
"use strict";
const FEATURE_MATRIX = root.VoiceOfMLReader.features;
function createEventBus() {
const listeners = new Map();
let disposed = false;
function on(type, listener) {
if (disposed) return () => {};
const set = listeners.get(type) || new Set();
set.add(listener);
listeners.set(type, set);
return () => {
set.delete(listener);
if (!set.size) listeners.delete(type);
};
}
function emit(type, detail) {
if (disposed) return;
for (const listener of [...(listeners.get(type) || []), ...(listeners.get("*") || [])])
listener(detail, type);
}
function dispose() {
disposed = true;
listeners.clear();
}
return Object.freeze({
on,
emit,
dispose,
get disposed() {
return disposed;
}
});
}
function createReaderRuntime(options = {}) {
const events = options.events || createEventBus();
const state = {
lifecycle: { phase: "startup", stage: "startup", disposed: false, errorCode: "" },
source: { id: "", url: "", contentUrl: "", downloadUrl: "", extension: "", metadata: null },
document: {
title: "",
zoom: 1,
page: 1,
pageCount: 0,
restoredEntry: null,
restorationReady: false
},
navigation: { tocEntries: [], currentChapterIndex: -1 },
search: { query: "", results: [], index: -1 },
panel: { open: false, selected: "toc", showingAllBookmarks: false, editingBookmark: null },
formats: { active: null, pdf: {}, foliate: {}, html: {}, media: {} },
capability: null,
...(options.initialState || {})
};
const generations = new Map();
const disposables = new Set();
const timers = new Set();
const frames = new Set();
function snapshot() {
return Object.freeze({
lifecycle: Object.freeze({ ...state.lifecycle }),
source: Object.freeze({ ...state.source }),
document: Object.freeze({ ...state.document }),
navigation: Object.freeze({ ...state.navigation }),
search: Object.freeze({ ...state.search }),
panel: Object.freeze({ ...state.panel }),
formats: Object.freeze(
Object.fromEntries(
Object.entries(state.formats).map(([name, value]) => [
name,
Object.freeze({ ...value })
])
)
),
capability: state.capability
});
}
function update(domain, patch) {
if (state.lifecycle.disposed && domain !== "lifecycle") return false;
Object.assign(state[domain], patch);
const current = snapshot();
events.emit(`state:${domain}`, current);
events.emit("state", current);
return true;
}
function updateFormat(format, patch) {
if (state.lifecycle.disposed || !state.formats[format]) return false;
Object.assign(state.formats[format], patch);
events.emit(`format:${format}`, snapshot());
events.emit("state", snapshot());
return true;
}
function setPhase(phase) {
if (state.lifecycle.disposed && phase !== "disposed") return false;
state.lifecycle.phase = phase;
events.emit("phase", { phase, state: snapshot() });
return true;
}
function setStage(stage) {
if (!setPhase(stage)) return false;
state.lifecycle.stage = stage;
events.emit("stage", { stage, state: snapshot() });
return true;
}
function fail(code) {
state.lifecycle.errorCode = code;
setPhase("failed");
events.emit("error", { code, state: snapshot() });
}
function nextGeneration(name) {
const value = (generations.get(name) || 0) + 1;
generations.set(name, value);
events.emit("generation", { name, value });
return value;
}
function currentGeneration(name) {
return generations.get(name) || 0;
}
function isCurrent(name, value) {
return !state.lifecycle.disposed && generations.get(name) === value;
}
function track(disposable) {
if (disposable) disposables.add(disposable);
return disposable;
}
function untrack(disposable) {
disposables.delete(disposable);
}
function schedule(callback, delay = 0) {
const handle = setTimeout(() => {
timers.delete(handle);
callback();
}, delay);
timers.add(handle);
return handle;
}
function cancel(handle) {
if (!timers.has(handle)) return;
clearTimeout(handle);
timers.delete(handle);
}
function frame(callback) {
const handle = requestAnimationFrame(() => {
frames.delete(handle);
callback();
});
frames.add(handle);
return handle;
}
function cancelFrame(handle) {
if (!frames.has(handle)) return;
cancelAnimationFrame(handle);
frames.delete(handle);
}
function negotiate(capability) {
const mode = capability?.mode || "unsupported";
const value = Object.freeze({
...capability,
features: Object.freeze({
...(capability?.features || FEATURE_MATRIX[mode] || FEATURE_MATRIX.unsupported)
})
});
state.capability = value;
events.emit("capability", value);
return value;
}
function dispose() {
if (state.lifecycle.disposed) return;
state.lifecycle.disposed = true;
state.lifecycle.phase = "disposed";
for (const name of generations.keys()) nextGeneration(name);
for (const disposable of [...disposables].reverse()) {
try {
if (typeof disposable === "function") disposable();
else disposable.dispose?.();
} catch (_) {}
}
for (const handle of timers) clearTimeout(handle);
for (const handle of frames) cancelAnimationFrame(handle);
timers.clear();
frames.clear();
disposables.clear();
events.emit("phase", { phase: "disposed", state: snapshot() });
events.emit("dispose", snapshot());
events.dispose();
}
return Object.freeze({
state,
events,
snapshot,
update,
updateFormat,
setPhase,
setStage,
fail,
nextGeneration,
currentGeneration,
isCurrent,
track,
untrack,
schedule,
cancel,
frame,
cancelFrame,
negotiate,
dispose
});
}
root.VoiceOfMLReaderRuntime = Object.freeze({
FEATURE_MATRIX,
createEventBus,
createReaderRuntime
});
})(typeof self !== "undefined" ? self : globalThis);