liseur-sync/internal/webui/static/reader-app.js
Chmouel Boudjnah 940c942196
Some checks are pending
build / binary (push) Waiting to run
build / container (push) Waiting to run
test / test (push) Waiting to run
test / lint-openapi (push) Waiting to run
fix: Protect offline storage state
2026-09-08 00:11:44 +02:00

2678 lines
91 KiB
JavaScript

// Reader controls and native sync over the Readium publication engine.
import "./reader-engine.js";
import { openSession } from "./reader-session.js";
import { uploadSessions } from "./reader-session-upload.js";
import { positionTable, pageAt, pageLocation } from "./reader-positions.js";
import { readerAuth } from "./reader-auth.js";
import { offlineSync } from "./offline-sync.js";
import { liveStream } from "./reader-live.js";
import { catchupState, topicRefresh } from "./reader-sync.js";
import { annotationCFI, annotationAnchor, annotationRenderer } from "./reader-annotations.js";
import {
clearOfflineSessionCheckpoint,
accountContext,
claimOfflineReader,
assertOfflineContext,
notifyOfflineChange,
finishOfflineSession,
getOfflineSessionCheckpoint,
getReadySnapshot,
listOfflineAnnotations,
listOfflineOutbox,
removeOfflineOutbox,
queueOfflineAnnotation,
removeOfflineAnnotation,
saveOfflinePosition,
saveOfflineSessionCheckpoint,
deploymentPrefix,
storagePartition,
updateOfflineAnnotation,
} from "./offline-storage.js";
import * as CFI from "./vendor/foliate/epubcfi.js";
const el = document.getElementById("reader-config");
// Every URL is relative, computed server-side, so the reader keeps
// working when the UI is served under a stripped proxy subpath.
const cfg = {
bookID: el.dataset.book,
csrf: el.dataset.csrf,
tokenURL: el.dataset.tokenUrl,
downloadURL: el.dataset.downloadUrl,
apiBase: el.dataset.apiBase,
detached: el.dataset.detached === "1",
offline: el.dataset.offline === "1",
handed: null,
};
if (cfg.offline) {
cfg.bookID = new URL(location.href).searchParams.get("book") || "";
}
// On the separate reader origin (ADR-0007 phase 3) there is no session
// and no CSRF token, because there is no cookie on this hostname at
// all. The credential was handed over in the URL fragment, which the
// browser sent to nobody; the addresses it works against were in the
// query, checked by the server, and are already in the page.
if (cfg.detached) {
cfg.handed = new URLSearchParams(location.hash.slice(1)).get("t");
// Out of the address bar, out of the history entry, out of anything
// the user might paste to somebody. It stays in this module, which
// is where a credential belongs.
history.replaceState(null, "", location.pathname + location.search);
}
const stage = document.getElementById("reader-view");
const stageArea = stage.closest(".reader-stage") || stage;
const status = document.getElementById("reader-status");
const progressBar = document.getElementById("reader-progress-bar");
const progressText = document.getElementById("reader-progress-text");
const chapterText = document.getElementById("reader-chapter");
const pageText = document.getElementById("reader-page");
const footer = document.getElementById("reader-footer");
const gotoDialog = document.getElementById("reader-goto");
const gotoForm = document.getElementById("reader-goto-form");
const gotoTitle = document.getElementById("reader-goto-title");
const gotoLabel = document.getElementById("reader-goto-label");
const gotoInput = document.getElementById("reader-goto-input");
const gotoUnit = document.getElementById("reader-goto-unit");
const gotoCancel = document.getElementById("reader-goto-cancel");
let gotoKind = "percent";
const titleText = document.getElementById("reader-title-text");
const tocPanel = document.getElementById("reader-toc");
const tocList = document.getElementById("reader-toc-list");
const tocButton = document.getElementById("reader-toc-button");
const fullscreenBtn = document.getElementById("reader-fullscreen");
let view = null;
let workID = null;
let pending = null;
let here = null;
let faviconObjectURL = null;
let ready = false;
let readingDirty = false;
let interactionPending = false;
let restoring = true;
let lifecycle = 0;
let activityGeneration = 0;
let syncExpired = false;
const catchup = catchupState();
const catchupPanel = document.getElementById("reader-catchup");
const catchupText = document.getElementById("reader-catchup-text");
const catchupAccept = document.getElementById("reader-catchup-accept");
const catchupDismiss = document.getElementById("reader-catchup-dismiss");
// The book's positions, counted once when it opens. Null for a book
// this recipe cannot measure, which leaves the engine's own locations
// to say what page it is.
let positions = null;
let offlineSnapshot = null;
let offlineAccount = null;
let offlineCSRF = "";
let offlineCheckpoint = null;
let offlineContext = null;
let offlineCoordinator = null;
let releaseOfflineReader = null;
let offlineInvalidated = false;
const offlinePartition = cfg.offline ? storagePartition() : null;
const offlineBase = cfg.offline ? deploymentPrefix() : "";
function say(message, isError) {
status.textContent = message;
status.classList.toggle("problem", !!isError);
status.hidden = !message;
}
// ------------------------------------------------------------ auth
const auth = readerAuth({
...cfg,
apiBase: cfg.offline ? offlineBase : cfg.apiBase,
tokenURL: cfg.offline ? offlineBase + "ui/reader/token" : cfg.tokenURL,
csrf: () => cfg.offline ? offlineCSRF : cfg.csrf,
handed: cfg.handed,
onChange(identity, previous) {
if (!cfg.offline) offlineAccount = identity.account;
catchup.bind(identity.account, workID, identity.device);
hideCatchup();
if (!previous) return;
refreshes.reset();
live.stop();
annotationDrawing.clear().catch(() => {});
if (previous.account !== identity.account) {
// A same-account renewal already invalidates old-credential
// responses through auth.current(); only an actual account
// switch needs the page lifecycle to advance, or a write settled
// under the fresh token would be requeued as if it never landed.
lifecycle++;
auth.stop(); // A page opened for one account never writes under another.
return;
}
if (ready && !document.hidden) startLive();
},
onExhausted(err) {
if (cfg.offline) {
syncExpired = true;
say("Sign in again to sync offline changes.", true);
return;
}
syncExpired = true;
ready = false;
lifecycle++;
workID = null;
session = null;
unsent = [];
retryOp = null;
readingDirty = false;
cancelScheduledPush();
refreshes.stop();
live.stop();
catchup.bind(null, null, null);
hideCatchup();
annotationDrawing.clear().catch(() => {});
say(err.message, true);
},
});
cfg.handed = null;
const api = (path, options) => auth.request(path, options);
function snapshot() {
return { identity: auth.identity(), work: workID, view, lifecycle, offline: cfg.offline };
}
function current(stamp) {
if (cfg.offline) {
return !offlineInvalidated && !!stamp && stamp.offline && stamp.view === view &&
stamp.lifecycle === lifecycle;
}
return stamp && auth.current(stamp.identity) && stamp.work === workID &&
stamp.view === view && stamp.lifecycle === lifecycle;
}
function prepareOfflineSync() {
if (!offlineContext) return;
offlineCoordinator = offlineSync({
context: offlineContext, base: offlineBase,
onChange: async () => {
await loadAnnotations();
const fresh = await getReadySnapshot({ ...offlineContext, bookID: cfg.bookID });
if (fresh?.localPosition && !readingDirty) {
catchup.observe(fresh.localPosition);
showCatchup();
}
},
onStatus: message => say(message, !!message),
});
const retry = document.createElement("button");
retry.type = "button";
retry.className = "button secondary";
retry.textContent = "Retry sync";
retry.addEventListener("click", () => offlineCoordinator.trigger());
status.after(retry);
offlineCoordinator.trigger();
}
async function checkOfflineAccount() {
if (!offlineContext || offlineInvalidated) return;
try { await assertOfflineContext(offlineContext); }
catch {
offlineInvalidated = true;
lifecycle++;
session = null;
offlineCheckpoint = null;
readingDirty = false;
workID = null;
cancelScheduledPush();
offlineCoordinator?.stop();
await view?.destroy().catch(() => {});
stage.textContent = "";
view = null;
offlineSnapshot = null;
hideCatchup();
for (const element of [titleText, chapterText, progressText, pageText]) {
if (element) element.textContent = "";
}
await annotationDrawing.clear().catch(() => {});
releaseOfflineReader?.();
say("Offline access ended. Close this reader and sign in again.", true);
}
}
if (cfg.offline) {
const channel = new BroadcastChannel("liseur-offline");
channel.onmessage = () => checkOfflineAccount();
window.addEventListener("offline-change", checkOfflineAccount);
window.addEventListener("pageshow", checkOfflineAccount);
document.addEventListener("visibilitychange", checkOfflineAccount);
}
// ------------------------------------------------------------ sync
async function resolveWork() {
const resp = await api(
"v1/books/" + encodeURIComponent(cfg.bookID) + "/resolve",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
if (!resp.ok) return null;
const data = await resp.json();
return auth.responseCurrent(resp) ? data.work_id || null : null;
}
async function lastPosition() {
if (!workID) return { ok: false };
const work = workID;
const stamp = snapshot();
const resp = await api(
"v1/works/" + encodeURIComponent(work) + "/positions?limit=1",
);
if (!resp.ok) return { ok: false };
stamp.identity = auth.responseIdentity(resp);
const ops = (await resp.json()).ops || [];
if (work !== workID || !current(stamp)) return { ok: false };
return { ok: true, op: ops.length ? ops[0] : null };
}
// ------------------------------------------------------------- live
function hideCatchup() {
if (!catchupPanel) return;
const focused = catchupPanel.contains(document.activeElement);
catchupPanel.hidden = true;
if (focused) {
stage.tabIndex = -1;
stage.focus({ preventScroll: true });
}
}
function showCatchup() {
const offer = catchup.offer();
if (!offer || !catchupPanel) return;
const fraction = offer.op.progression;
catchupText.textContent = finite(fraction)
? `Continue from ${Math.round(fraction * 100)}% read on another device?`
: "Continue from the position read on another device?";
catchupPanel.hidden = false;
}
const refreshes = topicRefresh({
async refresh(topic) {
if (!ready || document.hidden || !workID) return false;
const run = lifecycle;
const activity = activityGeneration;
if (topic === "annotations") return loadAnnotations();
const result = await lastPosition();
if (!result.ok || document.hidden || run !== lifecycle ||
activity !== activityGeneration) return false;
catchup.observe(result.op);
showCatchup(); // The state allows this only after hidden -> visible.
return true;
},
});
const live = liveStream({
request: api,
current: (resp) => ready && !document.hidden && auth.responseCurrent(resp),
onTopics: (topics) => refreshes.owe(topics),
});
function startLive() {
if (!workID) return;
refreshes.start();
// This also refreshes on resume against a server without /v1/events.
refreshes.owe(["positions", "annotations"]);
live.start();
}
catchupDismiss?.addEventListener("click", () => {
catchup.dismiss();
hideCatchup();
});
catchupPanel?.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
catchup.dismiss();
hideCatchup();
}
});
catchupAccept?.addEventListener("click", async () => {
const shown = catchup.shown();
const activity = activityGeneration;
hideCatchup();
if (!shown || !view) return;
cancelScheduledPush();
await settlePosition();
// A failed flush leaves the local page dirty on purpose, so adopting
// the remote offer now would lose it for good the moment retryOp and
// readingDirty are cleared below.
if (readingDirty) return;
const stamp = snapshot();
const op = current(stamp) ? catchup.accept(shown) : null;
if (!op || !view) return;
retryOp = null;
readingDirty = false;
interactionPending = false;
// Close the old sitting at its actual page, not at the remote destination.
endSession();
restoring = true;
try {
for (const target of startCandidates(op)) {
if (!current(stamp) || document.hidden || activity !== activityGeneration) break;
try {
const resolved = await view.resolveNavigation(target);
if (!current(stamp) || document.hidden || activity !== activityGeneration) break;
// goTo catches anchor failures internally; use the renderer so a stale
// CFI actually descends to the existing fraction/href fallback.
await view.renderer.goTo(resolved);
break;
} catch { /* try the coarser locator */ }
}
} finally {
restoring = false;
// A restored page starts accounting only when the reader next interacts.
}
});
// ------------------------------------------------------ annotations
// View-only annotation rendering (ADR-0028). The live set comes from
// the same sync API every client uses; highlights whose CFI the engine
// can anchor draw through the vendored overlayer, and the rest — every
// bookmark, every note, and any highlight whose locator no longer
// resolves against this copy — degrade to a sidebar entry at their
// progression. Best-effort display, never an error.
const annPanel = document.getElementById("reader-annotations");
const annList = document.getElementById("reader-annotations-list");
// The palette is tokens on the wire, always; this table is the only
// place a token becomes CSS, so nothing a client pushed reaches the
// overlayer as a style.
const ANNOTATION_COLORS = {
yellow: "#ffd54f",
green: "#81c784",
blue: "#64b5f6",
pink: "#f06292",
purple: "#ba68c8",
orange: "#ffb74d",
};
const ANNOTATION_MAX_EXCERPT_BYTES = 1 << 10;
const ANNOTATION_MAX_BODY_BYTES = 16 << 10;
const annotationDrawing = annotationRenderer({
getView: () => view,
current,
changed: buildAnnotationList,
});
const annotationActions = document.getElementById("reader-annotation-actions");
const annotationColor = document.getElementById("reader-annotation-color");
const addNoteButton = document.getElementById("reader-add-note");
let selectedText = null;
function annotationStamp() {
return snapshot();
}
async function replaceAnnotations(next) {
return annotationDrawing.replace(next, annotationStamp());
}
function annotationPayload(annotation, baseRev = annotation.rev || 0) {
const payload = {
id: annotation.id,
base_rev: baseRev,
work_id: annotation.work_id,
kind: annotation.kind,
progression: annotation.progression,
excerpt: annotation.excerpt || "",
color: annotation.color || "",
body: annotation.body || "",
client_ts: annotation.client_ts || new Date().toISOString(),
};
if (annotation.locator) payload.locator = annotation.locator;
return payload;
}
function annotationValidationError(annotation) {
const bytes = value => new TextEncoder().encode(value || "").byteLength;
if (annotation.kind === "note" && !annotation.body?.trim())
return "A note requires a body.";
if (bytes(annotation.excerpt) > ANNOTATION_MAX_EXCERPT_BYTES)
return "That selection is too long to save as an annotation.";
if (bytes(annotation.body) > ANNOTATION_MAX_BODY_BYTES)
return "That note is too long to save.";
if (annotation.kind === "bookmark" && annotation.body)
return "A bookmark cannot have a note.";
return "";
}
async function applyAnnotationResult(annotation, result) {
if (result.status === "conflict" && result.server) {
await handleAnnotationConflict(annotation, result.server);
return false;
}
if (!["applied", "duplicate"].includes(result.status)) {
say(result.reason || "The annotation could not be saved.", true);
return false;
}
annotation.rev = result.rev || annotation.rev || 0;
annotation.seq = result.seq || annotation.seq || 0;
annotation.pending = false;
await replaceAnnotations(annotationDrawing.annotations());
return true;
}
async function writeAnnotation(annotation, baseRev = annotation.rev || 0) {
const validationError = annotationValidationError(annotation);
if (validationError) {
say(validationError, true);
return false;
}
const payload = annotationPayload(annotation, baseRev);
if (cfg.offline) {
annotation.pending = true;
await queueOfflineAnnotation({
...offlineContext,
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
annotation,
payload: { method: "write", annotation: payload },
});
notifyOfflineChange();
await replaceAnnotations(await listOfflineAnnotations({ ...offlineContext, bookID: cfg.bookID }));
return true;
}
const response = await api("v1/annotations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotations: [payload] }),
});
if (!response.ok) {
say("The annotation could not be saved.", true);
return false;
}
const body = await response.json().catch(() => null);
const result = body?.results?.[0];
if (!result) {
say("The annotation response was incomplete.", true);
return false;
}
return applyAnnotationResult(annotation, result);
}
async function deleteAnnotation(annotation) {
if (!window.confirm("Delete this annotation?")) return;
if (cfg.offline) {
const tombstone = { ...annotation, deleted: true, pending: true };
await queueOfflineAnnotation({
...offlineContext,
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
annotation: tombstone,
payload: { method: "delete", id: annotation.id, rev: annotation.rev || 0 },
});
notifyOfflineChange();
await replaceAnnotations(await listOfflineAnnotations({ ...offlineContext, bookID: cfg.bookID }));
return;
}
const response = await api(
"v1/annotations/" + encodeURIComponent(annotation.id) +
"?rev=" + encodeURIComponent(annotation.rev || 0),
{ method: "DELETE" },
);
if (response.status === 409) {
const body = await response.json().catch(() => null);
if (body?.server) await handleAnnotationConflict(
{ ...annotation, deleted: true, pending: true },
body.server,
);
return;
}
if (!response.ok) {
say("The annotation could not be deleted.", true);
return;
}
await replaceAnnotations(annotationDrawing.annotations().map(value =>
value.id === annotation.id ? { ...value, deleted: true } : value));
}
async function handleAnnotationConflict(local, server, record = null) {
const keepLocal = window.confirm(
"This annotation changed elsewhere. Choose OK to keep your local version, or Cancel to use the server version.",
);
if (keepLocal) {
local.rev = server.rev;
local.pending = true;
if (record) {
await queueOfflineAnnotation({
...offlineContext,
partition: storagePartition(),
account: offlineAccount,
bookID: cfg.bookID,
annotation: local,
payload: local.deleted
? { method: "delete", id: local.id, rev: server.rev }
: { method: "write", annotation: annotationPayload(local, server.rev) },
});
notifyOfflineChange();
} else if (local.deleted) {
const response = await api(
"v1/annotations/" + encodeURIComponent(local.id) +
"?rev=" + encodeURIComponent(server.rev),
{ method: "DELETE" },
);
if (!response.ok) {
say("The annotation could not be deleted.", true);
return;
}
} else {
await writeAnnotation(local, server.rev);
}
await replaceAnnotations([
...annotationDrawing.annotations().filter(value => value.id !== local.id),
local,
]);
return;
}
if (record) {
await removeOfflineOutbox({
...offlineContext,
partition: storagePartition(),
account: offlineAccount,
kind: record.kind,
id: record.id,
});
if (server.deleted) {
await removeOfflineAnnotation({
...offlineContext,
partition: storagePartition(),
account: offlineAccount,
bookID: cfg.bookID,
id: server.id,
removeAnnotation: false,
});
} else {
await updateOfflineAnnotation({
...offlineContext,
partition: storagePartition(),
account: offlineAccount,
bookID: cfg.bookID,
annotation: server,
});
}
}
await replaceAnnotations(annotationDrawing.annotations().map(value =>
value.id === local.id ? server : value));
}
async function resolveStoredAnnotationConflicts() {
if (!cfg.offline) return;
const conflicts = await listOfflineOutbox({
partition: storagePartition(),
account: offlineAccount,
kind: "annotation",
state: "conflict",
});
if (!conflicts.length) return;
const local = await listOfflineAnnotations({
partition: storagePartition(),
account: offlineAccount,
bookID: cfg.bookID,
});
for (const record of conflicts) {
if (record.bookID !== cfg.bookID || !record.details) continue;
const annotation = local.find(value => value.id === record.annotationID);
if (annotation) await handleAnnotationConflict(annotation, record.details, record);
}
}
function selectionFromDocument(doc) {
const selection = doc.getSelection?.();
if (!selection || !selection.rangeCount || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
const text = selection.toString().trim();
if (!text || !view?.getCFIForRange) return null;
return {
cfi: view.getCFIForRange(doc, range),
href: doc.documentElement.dataset.readerHref,
text: text.slice(0, 4096),
progression: finite(here?.fraction) ? here.fraction : null,
};
}
function showAnnotationActions() {
if (annotationActions) annotationActions.hidden = !selectedText;
}
function clearSelectedText() {
selectedText = null;
showAnnotationActions();
}
function annotationFromSelection(kind, body = "", color = "yellow") {
const selection = selectedText;
if (!selection || !workID) return null;
const attachedKind = kind === "note" ? "highlight" : kind;
return {
id: opID(),
rev: 0,
work_id: workID,
kind: attachedKind,
locator: {
href: selection.href,
type: "application/xhtml+xml",
locations: { fragments: [selection.cfi] },
text: { highlight: selection.text },
},
progression: selection.progression,
excerpt: selection.text,
color: attachedKind === "highlight" && ANNOTATION_COLORS[color] ? color : "",
body,
client_ts: new Date().toISOString(),
};
}
async function createSelectionAnnotation(action) {
if (!selectedText) return;
let body = "";
if (action === "note") {
body = window.prompt("Note for this passage:", "");
if (body === null || !body.trim()) return;
}
const annotation = annotationFromSelection(
action,
body.trim(),
annotationColor?.value || "yellow",
);
if (!annotation) return;
const validationError = annotationValidationError(annotation);
if (validationError) {
say(validationError, true);
return;
}
clearSelectedText();
await replaceAnnotations([...annotationDrawing.annotations(), annotation]);
await writeAnnotation(annotation);
}
async function createStandaloneNote() {
if (!workID) return;
const body = window.prompt("Note about this place in the book:", "");
if (body === null || !body.trim()) return;
const annotation = {
id: opID(),
rev: 0,
work_id: workID,
kind: "note",
progression: finite(here?.fraction) ? here.fraction : null,
body: body.trim(),
client_ts: new Date().toISOString(),
};
const validationError = annotationValidationError(annotation);
if (validationError) {
say(validationError, true);
return;
}
await replaceAnnotations([...annotationDrawing.annotations(), annotation]);
await writeAnnotation(annotation);
}
async function editAnnotation(annotation) {
const nextBody = window.prompt(
annotation.kind === "highlight" ? "Edit attached note:" : "Edit note:",
annotation.body || "",
);
if (nextBody === null) return;
let color = annotation.color;
if (annotation.kind === "highlight") {
color = window.prompt(
"Highlight color (yellow, green, blue, pink, purple or orange):",
annotation.color || "yellow",
);
if (color === null) return;
color = color.trim().toLowerCase();
if (!ANNOTATION_COLORS[color]) {
say("Choose one of the available highlight colors.", true);
return;
}
}
const next = { ...annotation, body: nextBody, color, client_ts: new Date().toISOString() };
const validationError = annotationValidationError(next);
if (validationError) {
say(validationError, true);
return;
}
await replaceAnnotations(annotationDrawing.annotations().map(value =>
value.id === annotation.id ? next : value));
await writeAnnotation(next, annotation.rev || 0);
}
function wireSelection(doc) {
const update = () => {
const next = selectionFromDocument(doc);
if (next) {
selectedText = next;
showAnnotationActions();
} else if (!annotationActions?.matches(":hover")) {
clearSelectedText();
}
};
doc.addEventListener("selectionchange", update);
doc.addEventListener("mouseup", () => setTimeout(update, 0));
doc.addEventListener("touchend", () => setTimeout(update, 0), { passive: true });
}
annotationActions?.addEventListener("click", event => {
const action = event.target?.dataset?.annotationAction;
if (action === "cancel") {
clearSelectedText();
return;
}
if (["highlight", "note", "bookmark"].includes(action))
createSelectionAnnotation(action).catch(error =>
say(error.message || "The annotation could not be created.", true));
});
addNoteButton?.addEventListener("click", () => {
createStandaloneNote().catch(error =>
say(error.message || "The note could not be created.", true));
});
// buildAnnotationList fills the sidebar with the entries that do not
// draw over the text. Excerpts and bodies are set as text nodes only.
function buildAnnotationList() {
if (!annPanel || !annList) return;
annList.textContent = "";
const listed = annotationDrawing.annotations().filter(
(a) => !a.deleted && (cfg.offline ||
a.kind !== "highlight" || !annotationAnchor(a) || annotationDrawing.failed(a.id)),
);
annPanel.hidden = !listed.length;
if (!listed.length) return;
const ul = document.createElement("ul");
for (const a of listed) {
const li = document.createElement("li");
const cfi = a.kind === "highlight" ? null : annotationCFI(a);
const canNavigate =
!!cfi || (typeof a.progression === "number" && a.progression >= 0);
const entry = document.createElement(canNavigate ? "a" : "span");
if (canNavigate) {
entry.href = "#";
entry.addEventListener("click", (e) => {
e.preventDefault();
if (!noteNavigation()) return;
toggleTOC(false);
if (!view) return;
const fall = () => {
if (typeof a.progression === "number") {
view.goToFraction(Math.min(0.999, a.progression)).catch(() => {});
}
};
if (cfi) view.goTo(cfi).catch(fall);
else fall();
});
}
const kind = document.createElement("span");
kind.className = "reader-ann-kind";
kind.textContent = a.kind + (a.pending ? " · pending" : "");
entry.append(kind);
const text = document.createElement("span");
text.className = "reader-ann-text";
text.textContent =
a.excerpt ||
a.body ||
(typeof a.progression === "number"
? Math.round(a.progression * 100) + "%"
: "");
entry.append(text);
li.append(entry);
const actions = document.createElement("span");
actions.className = "reader-ann-actions";
if (a.kind !== "bookmark") {
const edit = document.createElement("button");
edit.type = "button";
edit.textContent = "Edit";
edit.addEventListener("click", () => editAnnotation(a));
actions.append(edit);
}
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", () => deleteAnnotation(a));
actions.append(remove);
li.append(actions);
ul.append(li);
}
annList.append(ul);
}
// loadAnnotations is best-effort like every other sync call: a reader
// on a server without the routes, or offline, still reads the book.
async function loadAnnotations() {
if (!workID || !view) return false;
if (cfg.offline) {
await assertOfflineContext(offlineContext);
const local = await listOfflineAnnotations({
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
});
await replaceAnnotations(local);
await resolveStoredAnnotationConflicts();
return true;
}
const work = workID;
const stamp = snapshot();
const resp = await api("v1/works/" + encodeURIComponent(work) + "/annotations");
// An old server without annotation support is not a failing refresh.
if ([404, 501].includes(resp.status)) return true;
if (!resp.ok) return false;
stamp.identity = auth.responseIdentity(resp);
const data = await resp.json();
if (!current(stamp) || work !== workID || document.hidden) return false;
if (!Array.isArray(data.annotations)) return false;
await annotationDrawing.replace(data.annotations, stamp);
await resolveStoredAnnotationConflicts();
return current(stamp);
}
// bookTitle is read from the package document rather than from the
// catalog, so the page says what the file says even when the two have
// drifted. Readium keeps a title either as a string or as a
// language map, and either way one string comes out.
function bookTitle() {
const raw =
view && view.book && view.book.metadata && view.book.metadata.title;
if (!raw) return "";
if (typeof raw === "string") return raw;
const values = Object.values(raw);
return values.length ? String(values[0]) : "";
}
// finite guards a value that must be a real number the reader can act
// on. `typeof NaN === "number"` is true and `JSON.stringify(NaN)` emits
// null, so a bare typeof check would let a NaN fraction reach the wire
// as a "position unknown" the server records as the start of the book.
function finite(v) {
return typeof v === "number" && Number.isFinite(v);
}
// locatorFor builds the Readium Locator the sync protocol carries. The
// server stores it verbatim and never reads it, so the shape is a
// promise to the other clients rather than to the server.
//
// The CFI goes in `fragments`, which is where Readium puts a format's
// own pointer, and `totalProgression` is beside it because that is the
// one field every client can act on: a phone that has never heard of a
// CFI still opens in the right place.
//
// `location` here is what the Readium adapter hands the relocate event: a total
// fraction, the section index, and a CFI. The engine estimates the
// fraction from section sizes it already knows, so there is no separate
// "generate locations" pass and no moment when progress is unknown.
//
// A non-finite fraction yields null: there is no position to push, and
// the caller asks the engine to remeasure rather than record a wrong one.
function locatorFor(location) {
if (location.locator && finite(location.fraction)) return location.locator;
if (!finite(location.fraction)) return null;
const section = location.section || {};
const index = typeof section.current === "number" ? section.current : 0;
const sections = (view.book && view.book.sections) || [];
return {
href: (sections[index] && sections[index].id) || "",
type: "application/xhtml+xml",
title: bookTitle(),
locations: {
fragments: location.cfi ? [location.cfi] : [],
progression: sectionProgression(location),
totalProgression: location.fraction,
position: index + 1,
},
};
}
// sectionProgression recovers the fraction within the current section
// from the total fraction and the section boundaries, because Readium's
// `progression` is within-resource and the Readium adapter reports the total.
function sectionProgression(location) {
const fractions = view.getSectionFractions ? view.getSectionFractions() : [];
const section = location.section || {};
const index = typeof section.current === "number" ? section.current : 0;
const lo = fractions[index];
const hi = fractions[index + 1];
if (!finite(location.fraction)) return 0;
const total = location.fraction;
if (!finite(lo) || !finite(hi) || hi <= lo) return 0;
return Math.max(0, Math.min(1, (total - lo) / (hi - lo)));
}
// push records where we are. Failure is deliberately quiet: losing a
// position update is a smaller harm than an error banner over the page
// every time a laptop lid closes, and the next page turn retries.
//
// The op log is append-only and idempotent by op id, so a retry of the
// same position must replay the same op — the whole op, byte for byte,
// because a fresh client_ts under an old id is a different payload and
// the server rightly calls that a conflict. The op is therefore built
// once and kept until the server confirms it holds it; a different
// position is a different op and gets a new id. The server answers 200
// with a status per op: "applied" and "duplicate" both mean the log
// has it, and "conflict" means this id already belongs to another
// payload, where replaying is the one thing that cannot help.
let retryOp = null;
let positionInFlight = null;
let fractionRetryTimer = null;
let fractionRetryAttempt = 0;
const FRACTION_RETRY_DELAYS = [250, 750, 1500, 3000, 6000];
const FRACTION_WAIT_MESSAGE =
"Position sync is paused until the reader can measure this page.";
let fractionWaitShown = false;
function cancelScheduledPush() {
clearTimeout(pending);
pending = null;
}
function clearFractionRetry() {
clearTimeout(fractionRetryTimer);
fractionRetryTimer = null;
fractionRetryAttempt = 0;
if (fractionWaitShown && status.textContent === FRACTION_WAIT_MESSAGE) {
say("");
}
fractionWaitShown = false;
}
function scheduleFractionRetry() {
if (!view || !here || finite(here.fraction) || fractionRetryTimer) return;
if (document.hidden) return;
if (fractionRetryAttempt >= FRACTION_RETRY_DELAYS.length) {
fractionWaitShown = true;
say(FRACTION_WAIT_MESSAGE, true);
return;
}
const delay = FRACTION_RETRY_DELAYS[fractionRetryAttempt++];
fractionRetryTimer = setTimeout(() => {
fractionRetryTimer = null;
if (!view || !here || finite(here.fraction)) return;
if (document.hidden) return;
const retry = () => {
const renderer = view && view.renderer;
if (renderer && typeof renderer.render === "function") renderer.render();
if (!fractionRetryTimer && !finite(here && here.fraction)) {
scheduleFractionRetry();
}
};
requestAnimationFrame(retry);
}, delay);
}
async function push() {
if (!workID || !here || !readingDirty || restoring) return;
const stamp = snapshot();
const locator = locatorFor(here);
// A non-finite fraction has no position to record. Return without
// touching retryOp; the layout retry below will prod the engine for
// a new relocate, and the next finite one goes through the normal
// debounce.
if (!locator) {
scheduleFractionRetry();
return;
}
const key =
(locator.locations.fragments[0] || "") +
"@" +
locator.locations.totalProgression;
const op =
retryOp && retryOp.key === key
? retryOp.op
: {
op_id: opID(),
work_id: workID,
client_ts: new Date().toISOString(),
progression: locator.locations.totalProgression,
locator: locator,
};
retryOp = { key, op };
if (!cfg.offline) catchup.wrote(op);
if (cfg.offline) {
try {
await saveOfflinePosition({
...offlineContext,
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
op,
});
notifyOfflineChange();
if (retryOp?.op === op) {
retryOp = null;
readingDirty = false;
}
} catch (err) {
say(err.message || "Offline position could not be saved.", true);
}
return;
}
const request = api("v1/ops", {
method: "POST",
headers: { "Content-Type": "application/json" },
// keepalive lets the final flush outlive the page: without it a
// position pushed from beforeunload is cancelled mid-flight.
keepalive: true,
body: JSON.stringify({ ops: [op] }),
});
positionInFlight = request;
try {
const resp = await request;
stamp.identity = auth.responseIdentity(resp) || stamp.identity;
if (!resp.ok || !current(stamp) || !auth.responseCurrent(resp)) return;
const out = await resp.json().catch(() => null);
if (!current(stamp) || !auth.responseCurrent(resp)) return;
const status =
out && out.results && out.results[0] && out.results[0].status;
if (
status !== "applied" &&
status !== "duplicate" &&
status !== "conflict"
) {
return;
}
// Only this op's own outcome may clear it: a slower response
// arriving after the reader has moved on must not discard the op
// a newer push is still responsible for.
if (retryOp && retryOp.op === op) {
retryOp = null;
// A newer local page still needs its own push.
if (locatorFor(here)?.locations.fragments[0] === locator.locations.fragments[0] &&
here.fraction === locator.locations.totalProgression) readingDirty = false;
}
} catch (err) {
/* offline: the next page turn replays this exact op */
} finally {
if (positionInFlight === request) positionInFlight = null;
}
}
async function settlePosition() {
if (readingDirty && !restoring && !positionInFlight) await push();
if (positionInFlight) await positionInFlight;
}
// opID is a v4 UUID. crypto.randomUUID exists only in a secure
// context, and the reader origin may be plain HTTP on a LAN, so the
// bytes are drawn directly — getRandomValues has no such restriction
// — rather than letting sync fail quietly where TLS is absent.
function opID() {
if (crypto.randomUUID) return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = [...b].map((n) => n.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
function schedulePush() {
if (!readingDirty || restoring) return;
cancelScheduledPush();
pending = setTimeout(push, 1500);
}
// ------------------------------------------------------- sessions
// A sitting (ADR-0030) is bounded the way Android bounds one: it opens
// when the book is on screen with a position measured, and closes when
// the tab is hidden or unloaded, which is the last moment a browser
// reliably lets a page speak. Coming back opens a new one. The
// arithmetic — idle cap, minimum, clamps — lives in reader-session.js.
let session = null;
// Closed sittings the server has not yet confirmed. Each is kept whole
// and replayed byte for byte, like retryOp: the server compares
// payloads under the session id, and a figure that moved between
// attempts would be refused as a different session wearing the same
// name.
let unsent = [];
function beginSession() {
if (session || !workID || document.hidden || offlineInvalidated) return;
if (!here || !finite(here.fraction)) return;
session = openSession({
id: opID(),
workID: workID,
startedAt: new Date(),
now: performance.now(),
fraction: here.fraction,
supportsActiveMs: cfg.offline
? offlineSnapshot?.supportsActiveMs === true
: auth.identity()?.supportsActiveMs === true,
checkpoint: cfg.offline ? offlineCheckpoint : null,
});
offlineCheckpoint = null;
checkpointSession();
}
function checkpointSession() {
if (!cfg.offline || !session || !offlineAccount) return;
const checkpoint = session.checkpoint();
if (!checkpoint) return;
saveOfflineSessionCheckpoint({
...offlineContext,
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
checkpoint,
}).catch(error => say(
error.message || "Offline reading progress could not be saved.",
true,
));
}
function noteActivity() {
activityGeneration++;
if (document.hidden || restoring) return;
interactionPending = true;
if (session) {
session.activity(performance.now(), here && here.fraction);
checkpointSession();
} else {
beginSession();
}
if (unsent.length) pushSession();
}
function noteNavigation() {
if (!view || restoring || document.hidden || offlineInvalidated) return false;
noteActivity();
catchup.moved();
hideCatchup();
return true;
}
// noteProgress follows a relocate. It is not activity: the engine
// relocates on a resize or a font change too, and only a key, a tap or
// a scroll says somebody was there.
function noteProgress() {
if (session) {
session.progress(here && here.fraction);
checkpointSession();
}
}
async function endSession() {
if (!session) return;
const sessionID = session.checkpoint()?.id;
const payload = session.close(
performance.now(),
new Date(),
here && here.fraction,
);
session = null;
if (cfg.offline) {
try {
if (payload) {
await finishOfflineSession({
...offlineContext,
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
payload,
});
} else {
await clearOfflineSessionCheckpoint({
...offlineContext,
sessionID,
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
});
}
notifyOfflineChange();
} catch (error) {
say(error.message || "Offline reading session could not be saved.", true);
}
return;
}
if (payload) unsent.push(payload);
pushSession(true);
}
let sessionsInFlight = 0;
// pushSession sends every unconfirmed sitting in one batch. A retry
// prodded by activity waits for the request already out; a close does
// not, because the page may not be here when that one comes back, and
// the server holds the same payload twice as once.
async function pushSession(closing) {
if (!unsent.length || (sessionsInFlight && !closing)) return;
const batch = unsent.slice();
const stamp = snapshot();
sessionsInFlight++;
try {
await uploadSessions(batch, {
canSend: () => current(stamp),
send: async (body) => {
const resp = await api("v1/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
body,
});
stamp.identity = auth.responseIdentity(resp) || stamp.identity;
return resp;
},
responseCurrent: (resp) => current(stamp) && auth.responseCurrent(resp),
accepted: (sent) => { unsent = unsent.filter((p) => !sent.includes(p)); },
deferred: (status, code) => console.warn("Reading sessions are waiting to sync:", status, code),
refused: (item, code) => {
unsent = unsent.filter((p) => p !== item);
console.warn("Reading session refused:", code);
say("A reading session could not be saved; other reading will still sync.", true);
},
});
} catch (err) {
// The next activity or close retries the unchanged pending sittings.
if (!err.terminal) console.warn("Reading sessions are waiting to sync:", err);
} finally {
sessionsInFlight--;
}
}
document.addEventListener("visibilitychange", () => {
lifecycle++;
if (document.hidden) {
live.stop();
refreshes.stop();
catchup.hide();
hideCatchup();
cancelScheduledPush();
push();
endSession();
return;
}
catchup.resume();
if (ready) startLive();
beginSession();
if (here && !finite(here.fraction)) {
fractionRetryAttempt = 0;
scheduleFractionRetry();
}
});
window.addEventListener("pagehide", () => {
lifecycle++;
live.stop();
refreshes.stop();
catchup.hide();
hideCatchup();
endSession();
});
function cfiOf(op) {
const fragments =
(op &&
op.locator &&
op.locator.locations &&
op.locator.locations.fragments) ||
[];
for (const fragment of fragments) {
if (typeof fragment === "string" && fragment.indexOf("epubcfi(") === 0) {
return fragment;
}
}
return null;
}
// startCandidates lists where to try opening, best pointer first. It
// prefers what the writing client actually said — a CFI from this
// reader, or the resource another one named — and keeps the fraction
// every client agrees on as the fallback, which is why a book started
// on a phone opens in roughly the right place here. A stored pointer
// is only offered after the engine confirms it resolves against *this*
// copy of the book; but resolution here checks only the spine step, and
// a CFI's path inside the chapter is walked lazily after the chapter
// loads, where a pointer from another engine or edition can still fail.
// That is why this returns a ladder for the caller to descend rather
// than a single answer.
function startCandidates(op) {
if (!op) return [];
const resolves = (target) => {
try {
const resolved = view.resolveNavigation(target);
return (
resolved != null &&
typeof resolved.index === "number" &&
!!view.book.sections[resolved.index]
);
} catch (err) {
return false;
}
};
const out = [];
const cfi = cfiOf(op);
if (cfi && resolves(cfi)) out.push(cfi);
if (op.locator?.href && resolves(op.locator)) {
// The old reader used position for a spine index. Resource-relative
// anchors/progression survive the engine change; that index does not.
const locator = structuredClone(op.locator);
if (cfi) delete locator.locations.position;
out.push(locator);
}
const locations = (op.locator && op.locator.locations) || {};
const fraction =
typeof locations.totalProgression === "number"
? locations.totalProgression
: op.progression;
if (finite(fraction) && fraction >= 0) {
out.push({ fraction: Math.min(0.999, fraction) });
}
const href = op.locator && op.locator.href;
if (href && resolves(href)) out.push(href);
return out;
}
// ------------------------------------------------------ appearance
// Reader appearance, Komga-style: theme, font, size, spacing, layout.
// All of it is a browser preference — stored in localStorage, applied
// through the engine's user stylesheet and layout attributes, never
// sent to the server. The default for everything is "what the
// publisher said": a fresh reader renders the book exactly as shipped,
// and every override below exists only once the user asks for it.
const SETTINGS_KEY = "liseur.reader.settings";
// On a phone the browser chrome already consumes part of the viewport, and
// leaving our own bar visible takes another useful slice from the page. This
// is only the default: a saved choice still wins, and the setting remains
// available for readers who want the bar pinned.
const COMPACT_READER = /Android/i.test(navigator.userAgent) ||
window.matchMedia("(max-width: 700px)").matches;
const SETTINGS_DEFAULTS = Object.freeze({
theme: "original",
font: "publisher",
size: 100,
spacing: "0",
justify: false,
hyphenate: false,
flow: COMPACT_READER ? "scrolled" : "paginated",
columns: "auto",
margin: COMPACT_READER ? "narrow" : "normal",
autohide: COMPACT_READER,
footer: "chapter",
});
// What the footer's middle slot shows; a click on the footer walks
// this ring, the way a tap does in the app.
const FOOTER_MODES = ["chapter", "time-chapter", "time-book", "empty"];
const THEMES = {
light: { bg: "#ffffff", fg: "#1b1b1f", link: "#1a63c4", scheme: "light" },
sepia: { bg: "#f6ecd9", fg: "#5b4636", link: "#8a5a2b", scheme: "light" },
dark: { bg: "#202124", fg: "#cfcfd4", link: "#8ab4f8", scheme: "dark" },
"tokyo-night": {
bg: "#1a1b26",
fg: "#c0caf5",
link: "#7aa2f7",
scheme: "dark",
},
"rose-pine": {
bg: "#191724",
fg: "#e0def4",
link: "#c4a7e7",
scheme: "dark",
},
black: { bg: "#000000", fg: "#ababae", link: "#7aa2d8", scheme: "dark" },
};
const FONTS = {
serif: 'Georgia, "Times New Roman", "Liberation Serif", serif',
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, "Liberation Sans", sans-serif',
};
const MARGINS = { none: "8px", narrow: "16px", normal: "48px", wide: "72px" };
// The column gap follows the margin choice: "narrow" should mean the
// text gets the window, not just that the outer edge moved.
const GAPS = { none: "1%", narrow: "3%", normal: "7%", wide: "11%" };
let settings = loadSettings();
function loadSettings() {
try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY));
return { ...SETTINGS_DEFAULTS, ...(stored || {}) };
} catch (err) {
return { ...SETTINGS_DEFAULTS };
}
}
function saveSettings() {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch (err) {
/* private browsing: the settings last as long as the page */
}
}
// chapterCSS builds the user stylesheet the engine injects after the
// publication's own. Only what differs from the defaults appears, so
// with everything at "publisher" the sheet is one safety rule and the
// book's own design is untouched — which the browser test relies on
// when it reads the publication's colour back out of the page.
function chapterCSS(s) {
const rules = ["pre { white-space: pre-wrap !important; }"];
const theme = THEMES[s.theme];
if (theme) {
rules.push(
`html { color-scheme: ${theme.scheme}; }`,
`html, body { background: ${theme.bg} !important; color: ${theme.fg} !important; }`,
`body * { background-color: transparent !important; color: ${theme.fg} !important; }`,
`a:any-link { color: ${theme.link} !important; }`,
);
}
if (FONTS[s.font]) {
rules.push(
`body, body :not(pre):not(code):not(kbd):not(samp) { font-family: ${FONTS[s.font]} !important; }`,
);
}
const size = Number(s.size);
if (size && size !== 100) {
// Both rules matter: scaling the root handles publications that
// size text in rem/em, and forcing the body and paragraphs back to
// 1rem overrides the ones that pin type in px or CSS keywords
// (small, medium...), which would otherwise ignore the slider.
rules.push(
`html { font-size: ${size}% !important; }`,
"body { font-size: 1rem !important; }",
"p, li, blockquote, dd, dt, table, td, th { font-size: 1rem !important; }",
"h1 { font-size: 1.8rem !important; }",
"h2 { font-size: 1.5rem !important; }",
"h3 { font-size: 1.3rem !important; }",
"h4 { font-size: 1.15rem !important; }",
"h5 { font-size: 1rem !important; }",
"h6 { font-size: 0.9rem !important; }",
".lettrine, .dropcap, .first-letter { font-size: 2.5rem !important; line-height: 1 !important; }",
// A moved slider is the user taking over the typography, and
// that has to include the measure: books that cap their own
// text width (max-width on the body or a wrapper at any depth)
// would otherwise keep a ribbon of the old width pinned to the
// left of the wider column the reader lays out for the bigger
// type, and the growth would arrive as blank page instead of
// longer lines. Caps are lifted on wrappers however deep —
// width as well as max-width, since publishers use either to
// pin the measure — but side spacing is only zeroed at the top
// level: deeper margins and padding are indentation, not page
// geometry. The engine's own max-inline-size still bounds the
// line length.
"body, body div, body section, body article {" +
" max-width: none !important; max-inline-size: none !important;" +
" width: auto !important; inline-size: auto !important; }",
"body, body > div, body > section, body > article {" +
" margin-left: 0 !important; margin-right: 0 !important;" +
" padding-left: 0 !important; padding-right: 0 !important; }",
);
}
const spacing = Number(s.spacing);
if (spacing) {
rules.push(`p, li, blockquote, dd { line-height: ${spacing} !important; }`);
}
if (s.justify) {
rules.push(
"p, li, blockquote, dd { text-align: justify; }",
'[align="left"] { text-align: left; } [align="right"] { text-align: right; }',
'[align="center"] { text-align: center; }',
);
}
if (s.hyphenate) {
rules.push(
"p, li, blockquote, dd { -webkit-hyphens: auto; hyphens: auto; }",
);
}
return rules.join("\n");
}
function applySettings() {
document.body.dataset.readerTheme = settings.theme;
document.body.dataset.readerFlow =
settings.flow === "scrolled" ? "scrolled" : "paginated";
document.body.dataset.readerFooter = FOOTER_MODES.includes(settings.footer)
? settings.footer
: SETTINGS_DEFAULTS.footer;
// The footer lives in the bottom margin the engine leaves under the
// text, so its height is that margin, whatever the setting says.
document.body.style.setProperty(
"--reader-margin",
MARGINS[settings.margin] || MARGINS.normal,
);
applyChrome();
if (here) chapterText.textContent = footerMiddle(here);
if (!view || !view.renderer) return;
view.applySettings(settings, chapterCSS(settings), THEMES[settings.theme]);
}
const settingsPanel = document.getElementById("reader-settings");
const settingsForm = document.getElementById("reader-settings-form");
const sizeOut = document.getElementById("reader-size-out");
function syncSettingsForm() {
if (!settingsForm) return;
for (const field of settingsForm.elements) {
if (!field.name || !(field.name in settings)) continue;
if (field.type === "radio")
field.checked = field.value === String(settings[field.name]);
else if (field.type === "checkbox") field.checked = !!settings[field.name];
else field.value = String(settings[field.name]);
}
if (sizeOut) sizeOut.textContent = settings.size + "%";
}
function readSettingsForm() {
const data = new FormData(settingsForm);
settings = {
theme: String(data.get("theme") || SETTINGS_DEFAULTS.theme),
font: String(data.get("font") || SETTINGS_DEFAULTS.font),
size: Number(data.get("size")) || SETTINGS_DEFAULTS.size,
spacing: String(data.get("spacing") || SETTINGS_DEFAULTS.spacing),
justify: data.has("justify"),
hyphenate: data.has("hyphenate"),
flow: String(data.get("flow") || SETTINGS_DEFAULTS.flow),
columns: String(data.get("columns") || SETTINGS_DEFAULTS.columns),
margin: String(data.get("margin") || SETTINGS_DEFAULTS.margin),
autohide: data.has("autohide"),
footer: String(data.get("footer") || SETTINGS_DEFAULTS.footer),
};
if (sizeOut) sizeOut.textContent = settings.size + "%";
}
if (settingsForm) {
syncSettingsForm();
settingsForm.addEventListener("input", () => {
readSettingsForm();
saveSettings();
applySettings();
});
document
.getElementById("reader-settings-reset")
.addEventListener("click", () => {
settings = { ...SETTINGS_DEFAULTS };
syncSettingsForm();
saveSettings();
applySettings();
});
document.addEventListener("click", (e) => {
if (settingsPanel.open && !settingsPanel.contains(e.target)) {
settingsPanel.open = false;
}
});
}
// -------------------------------------------------------- fullscreen
const MAXIMIZE_PATH = "M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3m10 0h3a2 2 0 002-2v-3";
const MINIMIZE_PATH = "M4 14h3a2 2 0 012 2v3m6 0v-3a2 2 0 012-2h3M20 10h-3a2 2 0 01-2-2V5m-6 0v3a2 2 0 01-2 2H4";
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
}
if (fullscreenBtn) {
fullscreenBtn.addEventListener("click", toggleFullscreen);
document.addEventListener("fullscreenchange", () => {
const p = fullscreenBtn.querySelector("path");
if (document.fullscreenElement) {
p.setAttribute("d", MINIMIZE_PATH);
fullscreenBtn.title = "Exit full screen (f)";
} else {
p.setAttribute("d", MAXIMIZE_PATH);
fullscreenBtn.title = "Full screen (f)";
}
});
}
// ---------------------------------------------- chrome and tap zones
// The bar and the two arrows float over the book and step aside while
// nobody is reaching for them. What replaces them is the tap model
// here: the sides of the window turn pages wherever they are clicked —
// blank margin or the text itself — and the middle asks for the chrome
// back. A phone has no hover and no arrows to aim at once they have
// faded, so without this there would be no way left to turn a page.
//
// The zones are a share of the window rather than a fixed ribbon: a
// third of a phone is a thumb, a third of a desktop window is most of
// the column.
const CHROME_IDLE_MS = 2200;
const CHROME_TOUCH_MS = 4000;
const TAP_SLOP_PX = 10;
let chromePinned = false;
let chromeTimer = null;
function chromeAuto() {
return !!settings.autohide && !chromePinned;
}
function chromeVisible() {
return document.body.dataset.readerChrome !== "hidden";
}
function setChrome(visible) {
document.body.dataset.readerChrome = visible ? "visible" : "hidden";
}
// chromeBusy is every reason the bar cannot be taken away right now:
// something it owns is open, an error is on screen — a failure must
// never hide behind an invisible bar — or the keyboard is in it.
function chromeBusy() {
if (gotoDialog && gotoDialog.open) return true;
if (tocPanel && !tocPanel.hidden) return true;
if (settingsPanel && settingsPanel.open) return true;
if (status && !status.hidden) return true;
const help = document.getElementById("reader-help");
if (help && help.open) return true;
const active = document.activeElement;
return !!(
active &&
active.closest &&
active.closest(".reader-bar, .reader-turn")
);
}
function armChrome(delay) {
clearTimeout(chromeTimer);
if (!chromeAuto()) return;
chromeTimer = setTimeout(() => {
if (chromeBusy()) {
armChrome(delay);
return;
}
setChrome(false);
}, delay);
}
function revealChrome(delay) {
setChrome(true);
armChrome(delay || CHROME_IDLE_MS);
}
function applyChrome() {
if (!chromeAuto()) {
clearTimeout(chromeTimer);
setChrome(true);
return;
}
revealChrome();
}
function sideWidth() {
const w = window.innerWidth || 1;
return Math.max(64, Math.min(w * (w < 700 ? 0.3 : 0.22), 260));
}
function tapZone(x) {
const w = window.innerWidth || 1;
const side = sideWidth();
if (x <= side) return "prev";
if (x >= w - side) return "next";
return "chrome";
}
function inTopZone(y) {
return y <= Math.max(56, (window.innerHeight || 0) * 0.1);
}
// tapAt is the whole click model, in viewport coordinates: the stage,
// the engine's margins and every chapter document funnel here so that
// one rule covers the page whatever the click happened to land on.
function tapAt(x, y) {
const zone = tapZone(x);
if (zone !== "chrome") {
turn(zone === "prev" ? -1 : 1);
if (chromeVisible()) armChrome(CHROME_IDLE_MS);
return true;
}
if (!settings.autohide) {
setChrome(true);
return true;
}
if (chromeVisible()) {
clearTimeout(chromeTimer);
setChrome(false);
} else {
chromePinned = false;
revealChrome(CHROME_TOUCH_MS);
}
return true;
}
function toggleChrome() {
if (chromeVisible()) {
chromePinned = false;
clearTimeout(chromeTimer);
setChrome(false);
} else {
chromePinned = true;
clearTimeout(chromeTimer);
setChrome(true);
}
}
function pointerMoved(x, y, pointerType) {
if (pointerType === "touch") return; // a finger reveals by tapping
if (!chromeAuto()) return;
if (inTopZone(y)) revealChrome();
else if (chromeVisible()) armChrome(CHROME_IDLE_MS);
}
document.addEventListener(
"pointermove",
(e) => pointerMoved(e.clientX, e.clientY, e.pointerType),
{ passive: true },
);
document.addEventListener("focusin", () => {
if (chromeAuto() && chromeBusy()) revealChrome();
});
// A chapter is a document of its own, so nothing that happens over the
// text reaches this page: pointer and click handling has to be wired
// per chapter, the way the reading keys already are. Its coordinates
// are the frame's, and the frame knows where it sits.
// A coordinate inside a chapter is the chapter's, and the reader thinks
// in the window's. The frame says where it sits — and, for a
// fixed-layout publication, how much the engine scaled it: those frames
// carry a CSS transform, so the rectangle is in rendered pixels while
// the coordinate inside it is in the document's own. A reflowable book
// scales by 1 and passes through unchanged.
function frameOffset(doc) {
try {
const el = doc.defaultView && doc.defaultView.frameElement;
if (el) {
const box = el.getBoundingClientRect();
const scale = el.offsetWidth ? box.width / el.offsetWidth : 1;
return { left: box.left, top: box.top, scale: scale || 1 };
}
} catch (err) {
/* the frame was replaced between events */
}
const box = stage.getBoundingClientRect();
return { left: box.left, top: box.top, scale: 1 };
}
function toViewport(doc, x, y) {
const box = frameOffset(doc);
return [box.left + x * box.scale, box.top + y * box.scale];
}
// Everything in a publication that is already something when it is
// clicked: a control, a link, a media player, an image map, a frame.
// Clicking one of those is doing that thing, not turning a page.
const TAP_EXEMPT =
"a,button,input,textarea,select,summary,label,audio,video,area[href]," +
"iframe,embed,object,[contenteditable],[role='button'],[role='link']";
// Long enough to cover the usual platform double-click intervals
// (Windows and macOS both default to about half a second).
const DOUBLE_CLICK_MS = 500;
// How long after a tap the browser's synthesized click may still turn
// up. It is the same tap, and it has already been answered.
const TOUCH_CLICK_MS = 700;
// overText answers the only question that makes a mouse click
// ambiguous: is there a word under the pointer? A click on a line of
// text might be the first half of a double-click that selects it, so it
// has to wait; a click on a margin, a gutter or the space below the
// last line cannot be selecting anything and turns the page at once.
// caretRangeFromPoint alone is not enough — it answers with the
// *nearest* caret position even when the point is nowhere near it — so
// the character it names is measured and the point has to be inside it.
function overText(doc, x, y) {
const find = doc.caretRangeFromPoint || doc.caretPositionFromPoint;
if (!find) return true; // no way to tell: assume the careful answer
try {
const hit = find.call(doc, x, y);
if (!hit) return false;
const node = hit.startContainer || hit.offsetNode;
const offset = hit.startOffset ?? hit.offset ?? 0;
if (!node || node.nodeType !== 3 || !node.length) return false;
const at = Math.min(offset, node.length - 1);
const range = doc.createRange();
range.setStart(node, at);
range.setEnd(node, at + 1);
const box = range.getBoundingClientRect();
return (
x >= box.left - 2 &&
x <= box.right + 2 &&
y >= box.top - 2 &&
y <= box.bottom + 2
);
} catch (err) {
return true;
}
}
// A gesture is one pointer, from its own down to its own up. A second
// finger, a cancelled pointer or a stolen capture ends it: what happens
// next is a pinch, a scroll or the browser's business, never a tap.
function newGesture() {
return { id: null, x: 0, y: 0, spoiled: true, hadSelection: false };
}
function wireChapterPointer(doc) {
let gesture = newGesture();
let mouse = false;
let touchAt = 0;
let deferred = null;
const cancelDeferred = () => {
clearTimeout(deferred);
deferred = null;
};
const selected = () => {
const sel = doc.getSelection && doc.getSelection();
return !!(sel && sel.rangeCount && !sel.isCollapsed);
};
// Everything a tap has to not be, in one place: a drag, a gesture
// that was never ours to finish, a second click, a control — or a
// click that is putting a selection away rather than making one,
// which is why the selection is remembered from pointerdown: the
// browser collapses it before the click arrives, and by then the
// evidence is gone.
const isTap = (e) => {
if (gesture.spoiled) return false;
if (typeof e.button === "number" && e.button !== 0) return false;
if (e.detail > 1) return false;
if (gesture.hadSelection || selected()) return false;
return !(e.target && e.target.closest && e.target.closest(TAP_EXEMPT));
};
doc.addEventListener(
"pointermove",
(e) => {
pointerMoved(...toViewport(doc, e.clientX, e.clientY), e.pointerType);
},
{ passive: true },
);
// A scroll is the one way to move through a book that is neither a
// key nor a tap, so it counts as activity for the sitting.
doc.addEventListener("wheel", () => noteNavigation(), { passive: true });
doc.addEventListener(
"pointerdown",
(e) => {
noteActivity();
if (settingsPanel && settingsPanel.open) settingsPanel.open = false;
if (gesture.id !== null && gesture.id !== e.pointerId) {
gesture.spoiled = true; // a second finger: not a tap any more
return;
}
mouse = e.pointerType !== "touch" && e.pointerType !== "pen";
gesture = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
spoiled: false,
hadSelection: selected(),
};
},
{ passive: true },
);
const spoil = (e) => {
if (gesture.id === null || gesture.id === e.pointerId) gesture.spoiled = true;
};
doc.addEventListener("pointercancel", spoil, { passive: true });
doc.addEventListener("lostpointercapture", spoil, { passive: true });
// A finger is answered here rather than on the click that should
// follow it: the engine snaps the page on every touchend, and a
// scroll between touchend and the synthesized click cancels that
// click outright. Waiting for it would mean tapping a phone and
// watching nothing happen.
doc.addEventListener(
"pointerup",
(e) => {
if (gesture.id !== null && gesture.id !== e.pointerId) {
gesture.spoiled = true;
return;
}
if (
Math.abs(e.clientX - gesture.x) > TAP_SLOP_PX ||
Math.abs(e.clientY - gesture.y) > TAP_SLOP_PX
)
gesture.spoiled = true;
gesture.id = null;
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
touchAt = Date.now();
if (tocPanel && !tocPanel.hidden) {
toggleTOC(false); // the drawer cannot hear a tap inside the book
gesture.spoiled = true;
return;
}
if (!isTap(e)) return;
tapAt(...toViewport(doc, e.clientX, e.clientY));
},
{ passive: true },
);
// Double-clicking a word selects it, and the reader who did that
// wants the word, not the next page — but the first click of the
// pair arrives while the selection is still empty and looks exactly
// like a tap. A mouse click *on a word* therefore waits out the
// double-click interval and is cancelled if a second click, a
// selection or a dblclick follows. A mouse click anywhere else, and
// any touch or pen tap, turns the page immediately: there is nothing
// there to select, so there is nothing to wait for. (Beyond a
// half-second double-click interval the first click does turn a
// page; the reader turns back, and nothing is lost.)
doc.addEventListener("dblclick", cancelDeferred, true);
// A real second click starts with pointerdown, before the browser emits
// the second click/dblclick pair. Cancel here as well so a platform with
// a late or missing dblclick event cannot let the first deferred tap win.
doc.addEventListener("pointerdown", () => {
if (deferred) cancelDeferred();
}, true);
doc.addEventListener("selectstart", cancelDeferred);
// A tap on the text turns the page; a drag, a selection, a link or
// any other control is not a tap and is left entirely alone.
doc.addEventListener("click", (e) => {
if (e.detail > 1) {
cancelDeferred();
return;
}
// The tap this click belongs to was already answered on pointerup.
if (Date.now() - touchAt < TOUCH_CLICK_MS) return;
if (tocPanel && !tocPanel.hidden) {
toggleTOC(false);
gesture.spoiled = true;
return;
}
if (!isTap(e)) return;
const [x, y] = toViewport(doc, e.clientX, e.clientY);
if (!mouse || !overText(doc, e.clientX, e.clientY)) {
tapAt(x, y);
return;
}
cancelDeferred();
deferred = setTimeout(() => {
deferred = null;
const live = doc.getSelection && doc.getSelection();
if (live && live.rangeCount && !live.isCollapsed) return;
tapAt(x, y);
}, DOUBLE_CLICK_MS);
});
}
// The stage should match the chosen theme before the book arrives, so
// a dark reader does not open with a white flash.
applySettings();
// ---------------------------------------------------------- render
function paint(location) {
here = location;
// Leave the bar where it was when the fraction is unusable, so a
// transient NaN does not flash the progress back to 0%.
if (finite(location.fraction)) {
const fraction = location.fraction;
progressBar.style.width = (fraction * 100).toFixed(1) + "%";
progressText.textContent = Math.round(fraction * 100) + "%";
}
// The page is a Readium position: a fixed slice of the book as it is
// stored, so the count does not move when the font does and it is the
// same number the app shows for the same spot. A book the recipe
// cannot measure falls back to the engine's own locations. The same
// rule as the fraction — an unusable value leaves the last one up.
const page = readerPage(location);
if (page) pageText.textContent = page;
const hasPageTotal = !!(
(positions && positions.total > 0) ||
(location.location && finite(location.location.total) && location.location.total > 0)
);
pageText.disabled = !hasPageTotal;
chapterText.textContent = footerMiddle(location);
markTOC(location.tocItem);
}
// readerPage is the footer's "n of m" for this spot, or null when
// neither the positions nor the engine can name one.
function readerPage(location) {
const section = location.section || {};
const n = pageAt(positions, section.current, location.sectionFraction);
if (n) return n + " of " + positions.total;
const loc = location.location || {};
if (finite(loc.current) && finite(loc.total) && loc.total > 0) {
return (
Math.min(Math.max(1, Math.floor(loc.current) + 1), loc.total) +
" of " +
loc.total
);
}
return null;
}
// footerMiddle is what the middle slot says for this spot under the
// chosen mode. A slot with nothing honest to say stays empty rather
// than inventing something.
function footerMiddle(location) {
const mode = FOOTER_MODES.includes(settings.footer)
? settings.footer
: SETTINGS_DEFAULTS.footer;
const time = location.time || {};
switch (mode) {
case "time-chapter":
return finite(time.section)
? durationText(time.section) + " left in chapter"
: "";
case "time-book":
return finite(time.total) ? durationText(time.total) + " left in book" : "";
case "empty":
return "";
default: {
// The chapter title the book itself gives this spot, falling back
// to a plain count when the navigation has no entry covering it.
const tocItem = location.tocItem;
if (tocItem && tocItem.label) return tocItem.label.trim();
const section = location.section || {};
if (typeof section.current === "number" && section.total) {
return "Chapter " + (section.current + 1) + " of " + section.total;
}
return "";
}
}
}
// durationText spells minutes the way the app does: "45 mins",
// "2 hrs 5 mins", or a friendly line for nearly nothing left.
function durationText(minutes) {
const total = Math.round(minutes);
if (total < 1) return "Less than a minute";
const hours = Math.floor(total / 60);
const rest = total % 60;
const mins = rest + (rest === 1 ? " min" : " mins");
if (hours === 0) return mins;
const hrs = hours + (hours === 1 ? " hr" : " hrs");
return rest === 0 ? hrs : hrs + " " + mins;
}
// A click on the footer cycles the middle slot, and only that: the
// footer is not a stage surface, so the page under it stays put.
function cycleFooter() {
const i = FOOTER_MODES.indexOf(settings.footer);
settings = {
...settings,
footer: FOOTER_MODES[(i + 1) % FOOTER_MODES.length],
};
saveSettings();
syncSettingsForm();
applySettings();
}
async function goToPage(page) {
if (!view) return;
if (positions) {
const loc = pageLocation(positions, page);
if (loc && view.renderer) {
try {
await view.renderer.goTo(loc);
const landedIndex = view.lastLocation?.section?.current;
const state =
landedIndex === loc.index && view.lastLocation?.cfi
? view.lastLocation.cfi
: loc.index;
if (view.history && view.history.pushState) {
view.history.pushState(state);
}
return;
} catch (e) {
/* fall through to engine fraction fallback */
}
}
}
const loc = here && here.location;
if (loc && finite(loc.total) && loc.total > 0) {
const frac = Math.min(Math.max((page - 0.5) / loc.total, 0), 1);
await view.goToFraction(frac).catch(() => {});
}
}
function openGoto(kind) {
if (!gotoDialog || !gotoInput) return;
gotoKind = kind;
if (kind === "percent") {
if (gotoTitle) gotoTitle.textContent = "Go to percentage";
if (gotoLabel) gotoLabel.textContent = "Percentage";
gotoInput.min = "0";
gotoInput.max = "100";
gotoInput.step = "1";
gotoInput.value =
here && finite(here.fraction) ? String(Math.round(here.fraction * 100)) : "";
if (gotoUnit) gotoUnit.textContent = "%";
} else {
const total = positions
? positions.total
: here && here.location && finite(here.location.total) && here.location.total > 0
? here.location.total
: null;
if (!total) return;
if (gotoTitle) gotoTitle.textContent = "Go to page";
if (gotoLabel) gotoLabel.textContent = "Page number";
gotoInput.min = "1";
gotoInput.max = String(total);
gotoInput.step = "1";
let current = null;
if (positions && here && here.section) {
current = pageAt(positions, here.section.current, here.sectionFraction);
} else if (
here &&
here.location &&
finite(here.location.current) &&
finite(here.location.total)
) {
current = Math.min(
Math.max(1, Math.floor(here.location.current) + 1),
here.location.total,
);
}
gotoInput.value = current !== null ? String(current) : "";
if (gotoUnit) gotoUnit.textContent = "of " + total;
}
gotoDialog.showModal();
gotoInput.select();
}
if (footer) {
footer.addEventListener("click", (e) => {
const btn = e.target && e.target.closest ? e.target.closest("button") : null;
if (btn === progressText) {
openGoto("percent");
} else if (btn === pageText) {
openGoto("page");
} else {
cycleFooter();
}
});
}
if (gotoForm) {
gotoForm.addEventListener("submit", async (e) => {
e.preventDefault();
const val = parseFloat(gotoInput.value);
if (!Number.isFinite(val)) return;
if (!noteNavigation()) return;
if (gotoKind === "percent") {
const pct = Math.min(Math.max(val, 0), 100);
if (view) await view.goToFraction(pct / 100).catch(() => {});
} else {
await goToPage(val);
}
if (gotoDialog && gotoDialog.open) gotoDialog.close();
});
}
if (gotoCancel) {
gotoCancel.addEventListener("click", () => {
if (gotoDialog && gotoDialog.open) gotoDialog.close();
});
}
if (gotoDialog) {
gotoDialog.addEventListener("click", (e) => {
if (e.target !== gotoDialog) return;
const rect = gotoDialog.getBoundingClientRect();
const inDialog =
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
if (!inDialog) gotoDialog.close();
});
}
// ------------------------------------------------------- contents
// buildTOC turns the publication's navigation into the drawer's list.
// Labels are set as text, never as markup — the TOC is publication
// content like everything else. Entries without an href (bare section
// headings) render as labels that cannot be followed.
function buildTOC(items) {
tocList.textContent = "";
if (!items || !items.length) {
if (tocButton) tocButton.hidden = true;
return;
}
const make = (list) => {
const ul = document.createElement("ul");
for (const item of list || []) {
const li = document.createElement("li");
if (item.href) {
const a = document.createElement("a");
a.href = "#";
a.textContent = (item.label || "").trim() || item.href;
a.dataset.href = item.href;
li.append(a);
} else {
const span = document.createElement("span");
span.textContent = (item.label || "").trim();
li.append(span);
}
if (item.subitems && item.subitems.length) li.append(make(item.subitems));
ul.append(li);
}
return ul;
};
tocList.append(make(items));
}
function markTOC(tocItem) {
if (!tocList) return;
const before = tocList.querySelector("a.current");
if (before) before.classList.remove("current");
if (!tocItem || !tocItem.href) return;
for (const a of tocList.querySelectorAll("a")) {
if (a.dataset.href === tocItem.href) {
a.classList.add("current");
break;
}
}
}
function toggleTOC(open) {
const want = typeof open === "boolean" ? open : tocPanel.hidden;
tocPanel.hidden = !want;
if (want) {
// The drawer belongs to the button in the bar, so the bar comes
// back with it however the drawer was opened.
revealChrome();
const current =
tocList.querySelector("a.current") || tocList.querySelector("a");
if (current) current.focus();
if (current) current.scrollIntoView({ block: "center" });
}
}
if (tocButton) tocButton.addEventListener("click", () => toggleTOC());
// A click anywhere outside the drawer puts it away, the way a drawer
// behaves everywhere else.
document.addEventListener("click", (e) => {
if (!tocPanel || tocPanel.hidden) return;
if (tocPanel.contains(e.target)) return;
if (tocButton && tocButton.contains(e.target)) return;
toggleTOC(false);
});
tocList.addEventListener("click", (e) => {
const a = e.target && e.target.closest && e.target.closest("a[data-href]");
if (!a) return;
e.preventDefault();
if (!noteNavigation()) return;
toggleTOC(false);
if (view) view.goTo(a.dataset.href).catch(() => {});
});
function turn(direction) {
if (!noteNavigation()) return undefined;
return direction > 0 ? view.goRight() : view.goLeft();
}
document.getElementById("reader-next").addEventListener("click", () => turn(1));
document
.getElementById("reader-prev")
.addEventListener("click", () => turn(-1));
// Any blank margin is a page turn: a click that lands on the stage or
// on the engine's own chrome (margins, gaps, header, footer — all of
// which retarget to the readium-view host)
// goes through the same tap zones as the text, so the sides turn the
// page and the middle brings the bar back. Clicks inside the chapter
// itself are handled in that chapter's own document, where a link or a
// live selection can still be told apart from a tap.
// A tap on the margin, rather than on the text: the same rule, and the
// same reason for handling touch on pointerup. The engine snaps the
// page on every touchend, and a scroll between touchend and the click
// the browser would have synthesized cancels that click — a finger
// would tap and nothing would happen. When the click does arrive it is
// the same tap arriving twice, so a short window swallows it.
// A click on the margin while a passage is selected is putting the
// selection away, the same as a click on the text: the selection lives
// in the chapter, so the margin has to go and ask.
function bookHasSelection() {
try {
const contents =
view && view.renderer && view.renderer.getContents
? view.renderer.getContents()
: [];
for (const content of contents) {
const doc = content && content.doc;
const sel = doc && doc.getSelection && doc.getSelection();
if (sel && sel.rangeCount && !sel.isCollapsed) return true;
}
} catch (err) {
/* the chapter went away mid-gesture */
}
return false;
}
let stageTouchAt = 0;
let stageGesture = newGesture();
const stageSurface = (target) =>
target === stageArea || target === stage || target === view;
stageArea.addEventListener(
"pointerdown",
(e) => {
if (stageGesture.id !== null && stageGesture.id !== e.pointerId) {
stageGesture.spoiled = true;
return;
}
stageGesture = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
spoiled: false,
hadSelection: bookHasSelection(),
};
},
{ passive: true },
);
const spoilStage = (e) => {
if (stageGesture.id === null || stageGesture.id === e.pointerId)
stageGesture.spoiled = true;
};
stageArea.addEventListener("pointercancel", spoilStage, { passive: true });
stageArea.addEventListener("lostpointercapture", spoilStage, { passive: true });
stageArea.addEventListener("pointerup", (e) => {
noteActivity();
if (stageGesture.id !== null && stageGesture.id !== e.pointerId) {
stageGesture.spoiled = true;
return;
}
if (
Math.abs(e.clientX - stageGesture.x) > TAP_SLOP_PX ||
Math.abs(e.clientY - stageGesture.y) > TAP_SLOP_PX
)
stageGesture.spoiled = true; // a swipe: the engine's business
const clean = !stageGesture.spoiled;
stageGesture.id = null;
if (!view) return;
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
stageTouchAt = Date.now();
if (tocPanel && !tocPanel.hidden) {
toggleTOC(false);
return;
}
if (!clean) return;
if (stageGesture.hadSelection || bookHasSelection()) return;
if (!stageSurface(e.target)) return;
tapAt(e.clientX, e.clientY);
});
stageArea.addEventListener("click", (e) => {
if (!view) return;
if (tocPanel && !tocPanel.hidden) return; // the click puts the drawer away
if (!stageSurface(e.target)) return;
if (Date.now() - stageTouchAt < TOUCH_CLICK_MS) return; // already handled
if (stageGesture.hadSelection || bookHasSelection()) return;
tapAt(e.clientX, e.clientY);
});
// The standard reading keys. Space is the one every reader agrees on
// (Shift reverses it, as everywhere else); h/l and j/k are for hands
// that live on vim; Home and End are the covers. The handler is shared
// between the page and every chapter document, because after a click
// in the text the frame owns the keyboard and a page-level listener
// alone goes deaf — the engine re-emits its load event per chapter, so
// each document gets wired as it arrives.
function handleKeys(e) {
// Native buttons own Space/Enter; the reading shortcuts must not turn the
// publication or invalidate the offer while its controls have keyboard focus.
if (catchupPanel?.contains(e.target)) return;
noteActivity();
if (gotoDialog && gotoDialog.open) return;
const helpDialog = document.getElementById("reader-help");
// Arrow keys inside the settings panel adjust its controls, not the
// book; Escape puts the panel away from the keyboard.
if (settingsPanel && settingsPanel.contains(e.target)) {
if (e.key === "Escape") settingsPanel.open = false;
return;
}
if (e.key === "Escape" && settingsPanel && settingsPanel.open) {
settingsPanel.open = false;
return;
}
// While the contents drawer is up it owns the keyboard: Tab walks
// it, Enter follows, Escape or t puts it away — and nothing leaks
// through to turn a page underneath it.
if (tocPanel && !tocPanel.hidden) {
if (e.key === "Escape" || e.key === "t") {
e.preventDefault();
toggleTOC(false);
}
return;
}
// The catch-up offer never takes focus, so Escape typed in the
// publication (a separate document) or on the page must still reach
// it, the same as the drawer above.
if (catchupPanel && !catchupPanel.hidden && e.key === "Escape") {
e.preventDefault();
catchup.dismiss();
hideCatchup();
return;
}
// "?" summons the help from anywhere, including from inside a
// chapter document; the native dialog owns Escape and focus while
// it is up.
if (e.key === "?" && helpDialog && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
if (helpDialog.open) helpDialog.close();
else helpDialog.showModal();
return;
}
if (helpDialog && helpDialog.open) return;
// Modified keys belong to the browser, and keys aimed at a form
// field belong to the field.
if (e.ctrlKey || e.metaKey || e.altKey) return;
const tag = e.target && e.target.tagName;
if (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target && e.target.isContentEditable)
)
return;
switch (e.key) {
case " ":
e.preventDefault();
turn(e.shiftKey ? -1 : 1);
break;
case "ArrowRight":
case "PageDown":
case "l":
case "j":
e.preventDefault();
turn(1);
break;
case "ArrowLeft":
case "PageUp":
case "h":
case "k":
e.preventDefault();
turn(-1);
break;
case "t":
e.preventDefault();
toggleTOC(true);
break;
case "g":
e.preventDefault();
revealChrome();
openGoto("percent");
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
case "z":
e.preventDefault();
toggleChrome();
break;
case "Home":
if (noteNavigation()) view.goToFraction(0);
break;
case "End":
if (noteNavigation()) view.goToFraction(1);
break;
}
}
document.addEventListener("keydown", handleKeys);
window.addEventListener("beforeunload", () => {
clearTimeout(pending);
push();
endSession();
view?.destroy().catch(() => {});
});
// ------------------------------------------------------------ open
(async function start() {
try {
say("Opening the publication…");
view = document.createElement("readium-view");
stage.append(view);
view.addEventListener("relocate", (e) => {
paint(e.detail);
if (finite(e.detail.fraction)) {
clearFractionRetry();
if (!restoring) {
if (interactionPending) {
interactionPending = false;
readingDirty = true;
catchup.moved();
hideCatchup();
}
schedulePush();
noteProgress();
}
} else {
cancelScheduledPush();
scheduleFractionRetry();
}
});
// Each chapter document gets the reading keys: after a click in
// the text, the frame owns the keyboard, and a listener on the
// page alone would go deaf exactly when the reader looks focused.
// The pointer is wired there for the same reason — a tap on the
// text is how a phone turns a page once the arrows have faded.
view.addEventListener("load", (e) => {
e.detail.doc.addEventListener("keydown", handleKeys);
wireChapterPointer(e.detail.doc);
wireSelection(e.detail.doc);
});
view.addEventListener("link", (e) => {
if (!noteNavigation()) e.preventDefault();
});
const local = cfg.offline
? await getReadySnapshot({ partition: storagePartition(), bookID: cfg.bookID })
: null;
if (cfg.offline && !local) throw Error("This book is not available offline.");
if (cfg.offline) {
offlineSnapshot = local;
offlineAccount = local.account;
offlineContext = {
...await accountContext(offlinePartition, offlineAccount), deviceID: local.deviceID,
};
if (local.epoch !== offlineContext.epoch)
throw new Error("This offline copy belongs to an expired sign-in.");
releaseOfflineReader = await claimOfflineReader(offlineContext, cfg.bookID);
await assertOfflineContext(offlineContext);
workID = local.workID || local.localPosition?.work_id || null;
offlineCheckpoint = await getOfflineSessionCheckpoint({
partition: offlinePartition,
account: offlineAccount,
bookID: cfg.bookID,
});
}
await view.open({
request: cfg.offline ? local.request : api,
current: cfg.offline ? () => true : resp => auth.responseCurrent(resp),
bookID: cfg.bookID,
});
// Counted before the first relocate paints a footer, so the very
// first page the reader sees is already the app's number.
positions = positionTable(view.book.sections);
buildTOC(view.book.toc);
// The renderer exists once the book is open; settings applied here
// shape the very first page rather than repainting it.
applySettings();
const title = bookTitle();
if (title) {
titleText.textContent = title;
document.title = title + " · liseur-sync";
}
// The detached page could not link the cover: it lives on the API
// origin and a <link> fetch has no credential. Now that the token
// is in hand, the icon is swapped for it; a book without a cover
// gets the placeholder response, so the icon is still swapped for
// it. Same-origin pages linked the cover route from the start, so
// there is nothing to do.
if (!cfg.offline && cfg.detached) {
api("v1/books/" + encodeURIComponent(cfg.bookID) + "/cover?size=icon")
.then(async (resp) => {
if (!resp.ok) return;
const cover = await resp.blob();
if (!auth.responseCurrent(resp)) return;
const nextFaviconObjectURL = URL.createObjectURL(cover);
const link = document.querySelector("link[rel=icon]");
if (!link) {
URL.revokeObjectURL(nextFaviconObjectURL);
return;
}
if (faviconObjectURL) URL.revokeObjectURL(faviconObjectURL);
faviconObjectURL = nextFaviconObjectURL;
link.href = faviconObjectURL;
})
.catch(() => {});
}
// Sync is best-effort: a book still opens on a server that has
// lost its work mapping, it just opens at the beginning.
let op = cfg.offline ? offlineSnapshot.localPosition || null : null;
if (cfg.offline) {
catchup.bind(offlineAccount, workID, offlineSnapshot.deviceID);
catchup.baseline(op);
}
if (!cfg.offline) try {
workID = await resolveWork();
const identity = auth.identity();
catchup.bind(identity?.account, workID, identity?.device);
const result = await lastPosition();
if (result.ok) op = result.op;
catchup.baseline(op);
} catch (err) {
/* read on without sync */
}
// The candidates are tried in order because a pointer that
// resolved on paper can still fail in the chapter: the CFI's spine
// step is checked up front, but its path inside the document is
// only walked once the chapter has loaded, and a CFI minted by
// another engine or against another edition throws there. Each
// rung falls to the next; a book with no usable pointer at all
// opens at its text start rather than not at all.
let opened = false;
for (const target of startCandidates(op)) {
try {
await view.init({ lastLocation: target });
opened = true;
break;
} catch (err) {
/* stale pointer: descend to the coarser one */
}
}
if (!opened) await view.init({ lastLocation: null });
restoring = false;
ready = !cfg.offline && !syncExpired;
beginSession();
// The annotations arrive after the book is on screen: they are
// decoration on the text, never the reason the text waits.
if (cfg.offline) {
await loadAnnotations().catch(error =>
console.warn("Annotations could not be loaded:", error));
prepareOfflineSync();
}
if (!cfg.offline && ready && !document.hidden) startLive();
if (document.hidden) catchup.hide();
if (!syncExpired) say("");
} catch (err) {
releaseOfflineReader?.();
say((err && err.message) || "this book could not be opened", true);
}
})();