liseur-sync/internal/webui/static/reader-app.js
Chmouel Boudjnah d8000293ff
Some checks failed
test / lint-openapi (push) Has been cancelled
build / binary (push) Has been cancelled
build / container (push) Has been cancelled
test / test (push) Has been cancelled
fix: Improve mobile layout and touch handling in reader interface
* Adjusted safe area padding, touch target sizes, and fullscreen behavior across
mobile devices.
* Measured reader bar height dynamically to prevent UI overlaps during
orientation changes.
* Refined pointer and tap gesture tracking to prevent accidental triggers.

Signed-off-by: Chmouel Boudjnah <chmouel@chmouel.com>
2026-10-02 11:55:18 +02:00

4223 lines
156 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, readingSync, outboxLock } from "./offline-sync.js";
import { liveStream } from "./reader-live.js";
import { catchupState, topicRefresh, latestReadablePosition, positionAcknowledged } from "./reader-sync.js";
import { reconcileReadingState } from "./reader-reconcile.js";
import { agreedEdition, startCandidates as restoreCandidates } from "./reader-restore.js";
import { placeOf, placeHere, placeLabel, placeSentence, relativeAge, samePage } from "./reader-place.js";
import { decideBookSync } from "./reader-sync-choice.js";
import { markLocator } from "./reader-anchor.js";
import { annotationCFI, annotationAnchor, annotationRenderer } from "./reader-annotations.js";
import { fillPrompt } from "./reader-prompt.js";
import { buildChapters, chapterForLocation, pagesLeftInChapter } from "./reader-chapters.js";
import {
clearOfflineSessionCheckpoint,
accountContext,
accountVersion,
setActiveAccount,
claimOfflineReader,
assertOfflineContext,
notifyOfflineChange,
finishOfflineSession,
getOfflineSessionCheckpoint,
getReadySnapshot,
listOfflineAnnotations,
listOfflineOutbox,
removeOfflineOutbox,
retryOfflineOutbox,
discardOfflineOutbox,
queueOfflineAnnotation,
removeOfflineAnnotation,
restoreOfflineAnnotation,
saveReadingPosition,
readingState,
agreeReadingBaseline,
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",
promptTemplate: el.dataset.promptTemplate || "",
handed: null,
};
const annotationsEnabled = false;
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 progressRail = progressBar ? progressBar.closest(".reader-progress") : null;
const progressText = document.getElementById("reader-progress-text");
const chapterLoader = document.getElementById("reader-chapter-loader");
const chapterLoaderText = document.getElementById("reader-chapter-loader-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");
const promptButton = document.getElementById("reader-prompt-copy");
const promptFallback = document.getElementById("reader-prompt-fallback");
const promptFallbackText = document.getElementById("reader-prompt-fallback-text");
const promptFallbackCopy = document.getElementById("reader-prompt-fallback-copy");
// The bar grows with touch controls, safe areas and browser text sizing.
// Reserve its measured height so rotating an iPad cannot cover the book.
const readerBar = document.querySelector(".reader-bar");
if (readerBar) {
const sizeBar = () => document.body.style.setProperty(
"--reader-bar-height", `${readerBar.getBoundingClientRect().height}px`,
);
sizeBar();
new ResizeObserver(sizeBar).observe(readerBar);
}
let chapterLoadingTimer = null;
let chapterLoadingGeneration = 0;
const CHAPTER_LOADING_MIN_MS = 250;
function startChapterLoading(message = "Loading chapter…", delay = 0) {
const generation = ++chapterLoadingGeneration;
clearTimeout(chapterLoadingTimer);
if (!chapterLoader && !progressRail) return { show() {}, relabel() {}, stop() {} };
let shownAt = 0;
const show = (nextMessage = message) => {
if (generation !== chapterLoadingGeneration) return;
if (!shownAt) shownAt = performance.now();
if (chapterLoaderText) chapterLoaderText.textContent = nextMessage;
if (progressRail) progressRail.classList.add("loading");
if (chapterLoader) chapterLoader.hidden = false;
};
if (delay > 0) {
chapterLoadingTimer = setTimeout(show, delay);
} else {
show();
}
// Changes what an already visible loader says, and never brings one up.
const relabel = nextMessage => {
if (generation !== chapterLoadingGeneration || !shownAt) return;
if (chapterLoaderText) chapterLoaderText.textContent = nextMessage;
};
const stop = () => {
if (generation !== chapterLoadingGeneration) return;
clearTimeout(chapterLoadingTimer);
const hide = () => {
if (generation !== chapterLoadingGeneration) return;
if (progressRail) progressRail.classList.remove("loading");
if (chapterLoader) chapterLoader.hidden = true;
};
const remaining = shownAt
? CHAPTER_LOADING_MIN_MS - (performance.now() - shownAt)
: 0;
if (remaining > 0) chapterLoadingTimer = setTimeout(hide, remaining);
else hide();
};
return { show, relabel, stop };
}
function isChapterEdge(direction) {
if (!finite(here?.sectionFraction)) return false;
return direction > 0
? here.sectionFraction >= 0.99
: here.sectionFraction <= 0.01;
}
let view = null;
let workID = null;
let catalogEditionSHA = "";
// What a locator may weigh. The server's own default; it names a
// smaller one when it refuses a batch for size, and that is the only
// way to learn it, so this starts conservative rather than unbounded.
let locatorLimitBytes = 16 * 1024;
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 catchupDetail = document.getElementById("reader-catchup-detail");
const catchupExcerpt = document.getElementById("reader-catchup-excerpt");
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 chapters = null;
let offlineSnapshot = null;
let offlineAccount = null;
let offlineCSRF = "";
let offlineCheckpoint = null;
let offlineContext = null;
let offlineCoordinator = null;
let releaseOfflineReader = null;
// True when this page holds the book's reader claim, so it owns the
// single checkpoint slot for the sitting. A second tab reads on with
// its own fresh sitting rather than adopting this one's.
let sessionOwner = false;
let offlineInvalidated = false;
// An online reader on the deployment's own origin queues its reading
// state in the same IndexedDB the offline app uses, and drains it
// through the same lock. The separate reader origin (ADR-0007 phase 3)
// has no such storage of its own to share, so it keeps posting
// directly: that boundary is deliberate, not an oversight.
const durableSync = !cfg.offline && !cfg.detached;
const offlinePartition = cfg.detached ? null : storagePartition();
const offlineBase = cfg.offline ? deploymentPrefix() : "";
let readingCoordinator = null;
let sendTimer = null;
let sayTimer = null;
function say(message, isError) {
clearTimeout(sayTimer);
sayTimer = null;
status.textContent = message;
status.classList.toggle("problem", !!isError);
status.classList.remove("flash");
status.hidden = !message;
}
// flash is a message with nothing behind it: something worked, and there
// is nothing for the reader to do about it. The status sits over the
// middle of the page, which is exactly where the text is — and where a
// tap toggles the bars — so a line nobody has to act on takes itself
// away again and lets a tap through while it is up. Anything said
// afterwards cancels the timer rather than being wiped by it, and an
// error stays until whatever caused it is settled.
const FLASH_MS = 1600;
function flash(message) {
say(message);
status.classList.add("flash");
sayTimer = setTimeout(() => {
sayTimer = null;
if (status.textContent === message) say("");
}, FLASH_MS);
}
// ------------------------------------------- refused reading changes
const stuckPanel = document.getElementById("reader-stuck");
const stuckText = document.getElementById("reader-stuck-text");
const stuckRetry = document.getElementById("reader-stuck-retry");
const stuckDiscard = document.getElementById("reader-stuck-discard");
let stuck = [];
const stuckNames = {
position: "reading position",
session: "reading session",
annotation: "annotation",
};
function stuckSentence(records) {
if (records.length > 1) return `${records.length} reading changes could not be saved.`;
const [only] = records;
const name = stuckNames[only.kind] || "reading change";
return only.error
? `A ${name} could not be saved: ${only.error}.`
: `A ${name} could not be saved.`;
}
// showStuck names what the queue is holding and offers the two answers
// there are. An empty list takes the panel away, which is the whole
// point: every refusal must have an end.
function showStuck(records) {
stuck = records;
if (!stuckPanel) return;
stuckPanel.hidden = !records.length;
if (!records.length) return;
stuckText.textContent = stuckSentence(records);
}
// Editing the queue takes the queue's own lock, so a drain already in
// flight finishes before a record is revived or thrown away underneath
// it.
async function answerStuck(act, discarding) {
if (!offlineContext || !stuck.length) return;
const records = stuck;
showStuck([]);
const owned = [];
const work = async () => {
for (const record of records) {
const took = await act({ ...offlineContext, kind: record.kind, id: record.id });
if (took) owned.push(took);
}
};
let settled = false;
try {
if (navigator.locks) await navigator.locks.request(outboxLock(offlineContext), work);
else await work();
settled = true;
} catch { /* the queue is best-effort; the next drain reports again */ }
// Only a discard, and only for the annotations that discard actually
// spoke for. *Try again* restores nothing: it leaves the reader's
// payload queued, and the local copy is what that payload will
// deliver, so replacing it with the server's version would send one
// thing and show another. A record a newer mutation replaced owns
// nothing, and one whose discard threw never entered this list — so a
// later failure does not strand an earlier success unrestored.
if (discarding && owned.length) await restoreDiscardedAnnotations(owned);
if (!settled) console.warn("Not every stuck change could be answered.");
(readingCoordinator || offlineCoordinator)?.trigger();
}
// Discarding an annotation leaves a question the local store cannot
// answer. A rejected *edit* is still the reader's rejected text, so the
// discard leaves it marked unsaved, and only the server can say what
// the annotation really holds. The book's annotations are re-read and
// both the store and the page are set to what comes back: restored
// where the server has a version, removed where it has none.
//
// A reader who cannot reach the server keeps the unsaved marking and
// the page follows the local store, which is honest about what is
// known. The next drain asks again, because a drain only runs when
// there is something to talk to.
const unrestored = new Set();
async function restoreDiscardedAnnotations(ids) {
const owned = [...new Set(ids)];
if (!owned.length) return;
try {
// A mutation queued between the transaction and this request owns
// the annotation now, and its payload is what will be delivered.
const queued = await listOfflineOutbox({
...offlineContext, kind: "annotation", state: null,
}).catch(() => []);
const touched = owned.filter(id =>
!queued.some(record => record.annotationID === id));
for (const id of owned) {
if (!touched.includes(id)) unrestored.delete(id);
}
if (!touched.length) return;
let server = null;
if (workID) {
const response = await api(
"v1/works/" + encodeURIComponent(workID) + "/annotations",
).catch(() => null);
const data = response?.ok ? await response.json().catch(() => null) : null;
if (Array.isArray(data?.annotations)) server = data.annotations;
}
const stored = server ? [] : await listOfflineAnnotations({
partition: storagePartition(), account: offlineAccount, bookID: cfg.bookID,
});
const resolved = new Map();
for (const id of touched) {
if (!server) {
// Nothing authoritative to write, so the store is only read: the
// page stops drawing a note the discard took and keeps the rest
// marked unsaved.
resolved.set(id, stored.find(value => value.id === id) || null);
unrestored.add(id);
continue;
}
const fresh = server.find(value => value.id === id);
const annotation = fresh ? { ...fresh, pending: false } : null;
// The write refuses if a mutation arrived while the request was in
// the air, and only what was written may reach the page.
if (await restoreOfflineAnnotation({
...offlineContext, bookID: cfg.bookID, id, annotation,
})) {
resolved.set(id, annotation);
unrestored.delete(id);
}
}
const drawn = annotationDrawing.annotations();
const next = drawn
.filter(value => !resolved.has(value.id) || resolved.get(value.id))
.map(value => resolved.get(value.id) || value);
// A discarded deletion is an annotation coming back, so it may not
// be on the page at all.
for (const [id, annotation] of resolved) {
if (annotation && !drawn.some(value => value.id === id)) next.push(annotation);
}
await replaceAnnotations(next);
} catch (error) {
console.warn("The annotation list could not be refreshed:", error);
}
}
// Two drains can overlap, and the button can fire during one, so the
// re-ask runs one at a time like the conflict resolver above it. A
// drain is also the right moment for it: it only runs when there is a
// server to ask.
let restoringAnnotations = null;
function retryUnrestoredAnnotations() {
if (restoringAnnotations) return restoringAnnotations;
if (!unrestored.size) return Promise.resolve();
restoringAnnotations = restoreDiscardedAnnotations([...unrestored])
.finally(() => { restoringAnnotations = null; });
return restoringAnnotations;
}
stuckRetry?.addEventListener("click", () => { answerStuck(retryOfflineOutbox, false); });
stuckDiscard?.addEventListener("click", () => { answerStuck(discardOfflineOutbox, true); });
// ------------------------------------------------------------ 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;
bindPromptTemplate(identity.account);
refreshPromptTemplate(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;
catalogEditionSHA = "";
session = null;
unsent = [];
retryOp = null;
readingDirty = false;
cancelScheduledPush();
clearTimeout(sendTimer);
sendTimer = null;
readingCoordinator?.stop();
readingCoordinator = null;
releaseOfflineReader?.();
releaseOfflineReader = null;
sessionOwner = false;
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 () => {
const fresh = await getReadySnapshot({ ...offlineContext, bookID: cfg.bookID });
if (fresh?.localPosition && !readingDirty) {
catchup.observe(fresh.localPosition);
showCatchup();
}
await resolveAnnotationConflicts();
await retryUnrestoredAnnotations();
},
onStatus: message => say(message, !!message),
onStuck: showStuck,
});
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();
}
// prepareReadingSync claims the local queue for an online reader.
//
// The account marker is written from a credential this page actually
// holds — the token introspection said whose it is — so a reader opened
// straight from a bookmark queues durably without having to visit the
// library first. An unchanged account keeps its epoch, so downloaded
// books and anything already queued survive.
async function prepareReadingSync(identity) {
if (!durableSync || !identity?.account || !offlinePartition) return;
try {
const version = await accountVersion(offlinePartition);
const epoch = await setActiveAccount(offlinePartition, identity.account, version);
offlineContext = {
partition: offlinePartition, account: identity.account, epoch, deviceID: identity.device,
};
offlineAccount = identity.account;
} catch (error) {
// No local queue is a weaker reader, not a broken one: it posts
// straight through the way it always did.
console.warn("Reading changes cannot be queued locally:", error);
offlineContext = null;
return;
}
readingCoordinator = readingSync({
context: offlineContext,
request: (path, options) => api(path, options),
bookID: cfg.bookID,
onChange: async () => {
await refreshLocalReadingState();
await resolveAnnotationConflicts();
await retryUnrestoredAnnotations();
},
onStatus: message => { if (!syncExpired) say(message, !!message); },
onStuck: records => {
showStuck(records);
// The drain no longer speaks through the status line, so this is
// what clears a transient failure the coordinator put there.
if (!records.length && !syncExpired) say("");
},
});
}
// The durable state is the truth about what this device has said. It is
// re-read after every drain so an acknowledgement settles the baseline
// and a still-queued page keeps counting as local movement.
async function refreshLocalReadingState() {
if (!offlineContext || !durableSync) return;
const state = await readingState({ ...offlineContext, bookID: cfg.bookID }).catch(() => null);
if (!state) return;
const queued = await listOfflineOutbox({
...offlineContext, kind: "position", state: null,
}).catch(() => []);
const dirty = queued.some(record => record.deviceID === offlineContext.deviceID &&
record.bookID === cfg.bookID);
catchup.baseline(state.baseline);
catchup.local(state.local, dirty);
if (!dirty && !readingDirty) retryOp = null;
}
function scheduleSend() {
if (!readingCoordinator) return;
clearTimeout(sendTimer);
sendTimer = setTimeout(() => {
sendTimer = null;
readingCoordinator?.trigger();
}, 1500);
}
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?.();
releaseOfflineReader = null;
sessionOwner = false;
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();
if (!auth.responseCurrent(resp)) return null;
// The catalog's own digest for this book, kept so a position can name
// the edition it was read in. Cross-checked against the digest the
// engine actually opened rather than trusted on its own: the two can
// differ if the file changed between the manifest request and this
// one, and a locator filed under the wrong edition is worse than one
// filed under none.
catalogEditionSHA = (data.identifiers || [])
.find(id => id && id.kind === "sha256")?.value || "";
return data.work_id || null;
}
// editionSHA is the digest both the opened publication and the catalog
// agree on, or "" when they do not agree or either is unknown. An op
// carries it so the other client can tell whether this position is a
// place in the same bytes it has; an op without it is read as a
// position in an unnamed edition, which is what every op said before.
function editionSHA() {
return agreedEdition((view && view.editionSHA) || "", catalogEditionSHA);
}
async function lastPosition() {
if (!workID) return { ok: false };
const work = workID;
const stamp = snapshot();
const resp = await api(
"v1/works/" + encodeURIComponent(work) + "/positions?limit=20",
);
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: latestReadablePosition(ops, work) };
}
// ------------------------------------------------------------- live
function hideCatchup() {
if (!catchupPanel) return;
const focused = catchupPanel.contains(document.activeElement);
catchupPanel.hidden = true;
if (focused) {
stage.tabIndex = -1;
stage.focus({ preventScroll: true });
}
}
const percent = (fraction) =>
finite(fraction) ? `${Math.round(fraction * 100)}%` : null;
// The detail line carries what the question above it leaves out: the
// percentage behind a page number, and how long ago the other device
// was there. The percentage is repeated on purpose — it is the one
// number every client agrees on, and so the one to quote when two page
// counts disagree.
function placeDetail(place, name) {
if (!place) return null;
const said = [name, percent(place.fraction)].filter(Boolean).join(" ");
if (!said) return null;
const age = relativeAge(place.at);
return age ? `${said}, ${age}` : said;
}
// A passage from a document, so it is set as a text node and nothing
// else — another device's most of all. It is capped by the presenter;
// the two-line clamp is in the CSS.
function showExcerpt(element, place) {
if (!element) return;
const excerpt = place?.excerpt;
element.textContent = excerpt ? `“${excerpt}”` : "";
element.hidden = !excerpt;
}
function showCatchup() {
const offer = catchup.offer();
if (!offer || !catchupPanel) return;
const seen = placeView();
const there = placeOf(offer.op, seen);
// The near side is the page actually on screen, which is the one the
// reader can check by looking down; the op is only what to fall back
// on before the book has painted.
const painted = placeHere(here, seen);
const mine = painted || placeOf(offer.local, seen);
if (samePage(painted, there)) {
void agreeSamePage(offer);
return;
}
const thereLabel = placeLabel(there);
const mineLabel = placeLabel(mine);
catchupPanel.classList.toggle("conflict", offer.kind === "conflict");
if (offer.kind === "conflict") {
// Both sides moved since they last agreed. Say what both of them
// are; deciding which one the reader meant is not this program's
// business.
catchupText.textContent = mineLabel && thereLabel
? `This device is at ${mineLabel}; another device reached ${thereLabel}. Which one is where you are?`
: "This device and another device are in different places in this book.";
catchupAccept.textContent = thereLabel ? `Go to ${thereLabel}` : "Go to the other position";
catchupDismiss.textContent = mineLabel ? `Stay at ${mineLabel}` : "Stay here";
setDetail([placeDetail(mine, "Here"), placeDetail(there, "Another device")]);
} else {
catchupText.textContent = thereLabel
? `Continue from ${thereLabel} read on another device?`
: "Continue from the position read on another device?";
catchupAccept.textContent = "Continue there";
catchupDismiss.textContent = "Stay here";
setDetail([placeDetail(there, "")]);
}
showExcerpt(catchupExcerpt, there);
catchupPanel.hidden = false;
}
function setDetail(parts) {
if (!catchupDetail) return;
const line = parts.filter(Boolean).join(" · ");
catchupDetail.textContent = line;
catchupDetail.hidden = !line;
}
const refreshes = topicRefresh({
async refresh(topic) {
if (!ready || document.hidden || !workID) return false;
const run = lifecycle;
const activity = activityGeneration;
if (topic === "annotations") return true;
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"]);
live.start();
}
// Answering settles the disagreement for good: the position the reader
// did not take becomes the agreed baseline too, because it has been
// seen and answered. Without that, every reload asks again.
function rememberAnswer(op, settled) {
if (!op || !offlineContext) return;
agreeReadingBaseline({
...offlineContext, bookID: cfg.bookID, workID, baseline: op, settled,
}).catch(() => {});
}
// Taking the other device's position withdraws this device's own
// undelivered ones. The queue drains oldest first, so a page turn still
// waiting there would land after the reader's answer and reinstate the
// position they just refused. Nothing is lost: an op the server never
// acknowledged is a claim about where the reader was, and they have
// just said otherwise. It runs under the queue's own lock so a send
// already in flight finishes before the queue is edited underneath it.
async function withdrawQueuedPositions() {
if (!offlineContext) return;
const withdraw = async () => {
const queued = await listOfflineOutbox({
...offlineContext, kind: "position", state: null,
});
for (const record of queued) {
if (record.bookID !== cfg.bookID || record.deviceID !== offlineContext.deviceID) continue;
await removeOfflineOutbox({
...offlineContext, kind: "position", id: record.id, settle: false,
});
}
};
try {
if (navigator.locks) await navigator.locks.request(outboxLock(offlineContext), withdraw);
else await withdraw();
} catch { /* the queue is best-effort; a stale op is not worth an error */ }
}
// A question whose answer is the page in the reader's hand is not a
// question. Two clients on the same page almost never spell a CFI the
// same way, and the merge is right to call that movement — it is
// deciding what to *store*. This is deciding what to *ask*, and what
// settles that is the page table: an offer that names the page already
// on screen is answered the way the reader would answer it, by staying,
// and is never raised again.
//
// It costs nothing on the wire that was not owed already. `keepHere`
// writes no position when this page is clean, so a pull the book opened
// at is silent, exactly as an accepted restore is. A conflict is dirty
// by definition, so there the page being answered with goes up first,
// like every other answer.
let agreeingSamePage = null;
async function agreeSamePage(offer) {
// Keyed by the candidate *and* the binding it belongs to. The same
// remote position presented again under a new generation — a renewed
// credential rebinds the state — is a different question, and the
// answer in flight cannot answer it: that one is about to find its
// own stamp stale and stand down. Clearing is conditional for the
// same reason, so an older invocation finishing does not drop a
// guard a newer one now holds.
const key = offer.id + "\u0000" + offer.generation;
if (agreeingSamePage === key) return;
agreeingSamePage = key;
const stamp = snapshot();
try {
// The premise is read again at the end rather than trusted from the
// start: a flush takes as long as the network takes, and a resize, a
// font change or a reflow moves the reader without a page turn's
// `moved()`. An offer refused after its reason has gone would be the
// silent answer this exists to prevent. A flush that settled its own
// acknowledged page on the way is right and stands; only the other
// device's position is left unanswered, for a later quiet moment.
const answered = await keepHere(offer.op, () => {
const seen = placeView();
return catchup.shown() === offer && current(stamp) &&
samePage(placeHere(here, seen), placeOf(offer.op, seen));
});
// Usually there is no panel to take down: the page is checked
// before the question is drawn. But an offer already on screen can
// come to match, when a resize or a reflow moves the reader onto
// its page without a page turn, and a live refresh then re-enters
// here. An answered question must not be left standing with its
// buttons still live.
//
// It is taken down afterwards rather than first, and only when the
// answer was really recorded. Hiding first would have to put the
// panel back when the page would not go, and putting it back runs
// this gate again, which would fail the same way: a question that
// flickers for as long as the network is down. Left up, it is a
// question the reader can answer themselves, which is the honest
// state of it.
if (answered === "kept") hideCatchup();
} finally {
if (agreeingSamePage === key) agreeingSamePage = null;
}
}
// Every way of saying no goes through here: an answered offer settles
// durably, or the next open raises the same disagreement again.
async function dismissCatchup() {
const shown = catchup.shown();
hideCatchup();
if (await keepHere(shown ? shown.op : null) === "unsent") showCatchup();
}
// Staying put is an answer about the other device's position, not a
// refusal to answer: that position becomes the agreed baseline, and
// this device's own page is sent so the other side learns it too.
//
// The sending comes first, because the refusal is durable and the page
// it is answering with may not be. A baseline written on top of a page
// this device never managed to record would survive the reload that
// the page did not: the other position would count as answered and
// this one would be gone. So nothing is settled until the local page
// is safely in the queue — which, when it is already sitting there
// waiting to go up, it is.
//
// Sending takes as long as it takes, and the question stays usable
// while it does, so `still` is asked once more at the end: a reader who
// withdrew the question in the meantime gets no answer written on their
// behalf. Three outcomes — "kept", the answer stands; "unsent", the
// page would not go and nothing was settled, so put the question back;
// "withdrawn", the page went but the question is gone and whatever is
// on screen now belongs to somebody else.
async function keepHere(op, still) {
if (op && view && here && finite(here.fraction)) {
if (readingDirty) {
// Through the single-flight, never straight at `pushPosition`: an
// earlier page may still be uploading, and two positions in the
// air at once can land in the wrong order and make the older one
// this device's head — the server walked backwards.
if (!await settleBeforeAnswer()) return "unsent";
} else if (catchup.pending()) {
// Already written to the durable queue, which is all this answer
// needs; what it wants now is delivering, not writing again.
// `pushPosition` drops the retry key as soon as it has saved, so
// a push here would file the same spot under a second op id.
readingCoordinator?.trigger();
}
}
if (still && !still()) return "withdrawn";
catchup.refuse(op);
rememberAnswer(op, false);
return "kept";
}
// An answer must not be given on top of a page this device has not
// managed to send. A failed flush leaves the local page dirty on
// purpose, so adopting the remote position now would lose it for good
// the moment retryOp and readingDirty are cleared.
async function settleBeforeAnswer() {
cancelScheduledPush();
await settlePosition();
return !readingDirty;
}
// Going to the other device's position: the same journey whether the
// panel offered it or the reader asked. The answer itself has already
// been recorded by the caller; this is only the trip.
async function goThere(op, stamp, activity) {
if (!op || !view) return;
retryOp = null;
readingDirty = false;
interactionPending = false;
// Withdraw before anything else can drain the queue: ending the
// sitting triggers a send, and a page delivered after the answer
// would reinstate the position the reader just refused.
clearTimeout(sendTimer);
sendTimer = null;
await withdrawQueuedPositions();
if (!current(stamp) || !view) return;
// Close the old sitting at its actual page, not at the remote destination.
endSession();
restoring = true;
const loading = startChapterLoading("Syncing to reading position…");
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 {
loading.stop();
restoring = false;
// A restored page starts accounting only when the reader next interacts.
rememberAnswer(op, true);
}
}
catchupDismiss?.addEventListener("click", dismissCatchup);
catchupPanel?.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
void dismissCatchup();
}
});
catchupAccept?.addEventListener("click", async () => {
const shown = catchup.shown();
const activity = activityGeneration;
hideCatchup();
if (!shown || !view) return;
if (!await settleBeforeAnswer()) return;
const stamp = snapshot();
await goThere(current(stamp) ? catchup.accept(shown) : null, stamp, activity);
});
// ------------------------------------------- syncing on request
// The passive panel above appears when this reader happens to notice a
// disagreement. This is the other half (ADR-0040): the reader asks, and
// gets an answer even when the answer is "you are in step" — that is
// worth knowing, and a button that silently does nothing is not.
const syncButton = document.getElementById("reader-sync");
const syncDialog = document.getElementById("reader-sync-dialog");
const syncSummary = document.getElementById("reader-sync-summary");
const syncHereText = document.getElementById("reader-sync-here");
const syncHereExcerpt = document.getElementById("reader-sync-here-excerpt");
const syncThereText = document.getElementById("reader-sync-there");
const syncThereSide = document.getElementById("reader-sync-there-side");
const syncExcerpt = document.getElementById("reader-sync-excerpt");
const syncTake = document.getElementById("reader-sync-take");
const syncKeep = document.getElementById("reader-sync-keep");
const syncCancel = document.getElementById("reader-sync-cancel");
// The position the open dialog is asking about. Cleared when it closes,
// so an answer can never be given about a question no longer on screen.
let syncOffered = null;
let syncAsking = false;
// Whether the catch-up panel was up when this dialog opened. Cancelling
// changes nothing, and a disagreement that was already on screen and
// still unanswered is part of the nothing that must not change.
let syncCovered = false;
// This device's side, as an op, so the decision is made on the same two
// shapes everywhere: the same fields, the same anchor comparison the
// reconciler uses. It is never a position of zero when there is none —
// that would invent a side.
function positionHere() {
if (!view || !here || !finite(here.fraction)) return null;
return { progression: here.fraction, locator: locatorFor(here) || {} };
}
// Two of the summaries below promise this page is on its way up. The
// reader asked for that now, which is the whole point of the button, so
// it is sent rather than left to a retry timer that may be half a
// minute away. The op carries its own retry key, so a page already
// queued is delivered rather than written a second time.
function sendMineNow() {
if (!view || !here || !finite(here.fraction) || restoring) return;
// A page already written to the queue wants delivering, not writing
// again. The durable path drops the retry key the moment it has
// saved, so a second push here would mint a second op id for the
// same spot and file the reader's place twice for pressing twice.
if (!readingDirty && catchup.pending()) {
readingCoordinator?.trigger();
return;
}
readingDirty = true;
void push().catch(() => {}).then(() => { readingCoordinator?.trigger(); });
}
const SYNC_SUMMARIES = {
"no-remote": "No other device has a position for this book yet. This page is being sent, and will be there when one asks.",
"no-position": "Nothing has been read in this book yet, here or anywhere else. Your place will sync as soon as you have one.",
"in-step": "Both devices are in the same place. Nothing to do.",
"no-local": "Only your other device has read this book so far.",
"owed": "The server has an older copy of this device's own position. Nothing else has read this book, so this page is simply on its way up.",
"unreadable": "The position from your other device cannot be opened in this copy of the book. Keeping this page sends it instead.",
"ahead": "Your other device has read further than this one.",
"behind": "This device has read further than your other one.",
"same-page": "The same page, but not the same spot. The passage below is what the other device had on screen.",
"near-page": "Within a whisker of each other, but not the same spot. The passage below is what the other device had on screen.",
};
// `same-page` from the decision means only that the two progressions
// are within rounding of each other, and in a long book that is not
// enough to claim a page: a book with a thousand positions has several
// of them inside the same rounding. The numbers actually on screen are
// what settle it, and where there are none it says only what it knows.
function relationWording(decision, mine, there) {
const key = decision.relation || decision.verdict;
if (key !== "same-page") return key;
return samePage(mine, there) ? "same-page" : "near-page";
}
async function askBookSync() {
if (!syncDialog || syncAsking) return;
syncAsking = true;
syncButton?.setAttribute("aria-busy", "true");
try {
syncCovered = !!catchupPanel && !catchupPanel.hidden;
hideCatchup();
// The offline reader has no server to ask: its positions are
// queued on this device and go up when it is next online. Saying
// so is the whole of the answer, and nudging the queue is the
// whole of the action.
if (cfg.offline) {
offlineCoordinator?.trigger();
presentBookSync(null, "This copy is offline. Your reading is saved here and syncs when this device is next online.");
return;
}
const stamp = snapshot();
// A server that cannot be reached at all rejects rather than
// answering, and a button that turns an unreachable server into an
// unhandled rejection and no dialog is the silence this exists to
// end. Both failures mean the same thing to a reader.
const result = ready && workID
? await lastPosition().catch(() => ({ ok: false }))
: null;
if (!current(stamp)) return;
if (!result) {
presentBookSync(null, "This book is not syncing on this device.");
return;
}
if (!result.ok) {
presentBookSync(null, "The server could not be reached. Your reading is safe here and will be sent when it can.");
return;
}
catchup.observe(result.op);
presentBookSync(result.op, null);
} finally {
syncAsking = false;
syncButton?.removeAttribute("aria-busy");
}
}
// Nothing has been applied by the time this runs: the server's answer
// has been read, and neither side has been changed.
function presentBookSync(remote, note) {
const seen = placeView();
const mine = placeHere(here, seen);
const decision = note
? { verdict: "note" }
: decideBookSync({
local: positionHere(),
remote,
baseline: catchup.agreed(),
// `readingDirty` goes false the moment the page is written to the
// durable queue, which is before anyone has delivered it. Asking
// the queue instead is what keeps a page still waiting there from
// being reported as in step with a server that has never seen it.
localDirty: readingDirty || catchup.pending(),
resolvable: startCandidates(remote).length > 0,
});
// Taking the other side and keeping this one are two questions, and
// only a real choice asks both. With no page here there is nothing to
// keep: a Keep button would put the other device's position away
// without publishing anything in its place, leaving the reader with
// neither.
const takeable = decision.verdict === "ask" || decision.verdict === "no-local";
const keepable = decision.verdict === "ask" || decision.verdict === "unreadable";
if (decision.verdict === "no-remote" || decision.verdict === "owed") sendMineNow();
// A second side is shown when there is one to compare with. This
// reader's own position sitting on the server is not another device,
// and putting it under that heading would say something untrue.
const other = takeable || keepable ? remote : null;
const there = placeOf(other, seen);
syncOffered = other;
syncSummary.textContent = note ||
SYNC_SUMMARIES[relationWording(decision, mine, there)] || "";
syncHereText.textContent = placeLabel(mine) || "Not known yet";
syncThereText.textContent = placeSentence(there) || "";
syncThereSide.hidden = !other;
showExcerpt(syncHereExcerpt, mine);
showExcerpt(syncExcerpt, there);
syncTake.hidden = !takeable;
syncKeep.hidden = !keepable;
// With nothing to choose between, the only button left is the way
// out, and calling it "Cancel" would suggest something was pending.
syncCancel.textContent = syncTake.hidden && syncKeep.hidden ? "Close" : "Cancel";
if (!syncDialog.open) syncDialog.showModal();
}
syncButton?.addEventListener("click", () => void askBookSync());
syncCancel?.addEventListener("click", () => syncDialog.close());
// Cancelling is a real answer — "not this way" — and changes nothing at
// all, neither the page, nor what the two sides have agreed on, nor a
// question that was already waiting. An answer below clears the flag
// first, because answering the dialog answers the panel with it.
syncDialog?.addEventListener("close", () => {
syncOffered = null;
if (syncCovered) showCatchup();
syncCovered = false;
});
syncTake?.addEventListener("click", async () => {
const op = syncOffered;
const activity = activityGeneration;
if (!op || !view) {
syncCovered = false;
syncDialog.close();
return;
}
// The page this device has not managed to send goes up before the
// other one is adopted, or it is lost. If it will not go, the
// question stays open and says so: closing on a failure would take
// away the answer and the chance to give it again in one move.
if (!await settleBeforeAnswer()) {
syncSummary.textContent =
"This page could not be sent, so the other position was not taken. Try again in a moment.";
return;
}
// Sending can take a moment, and Cancel and Escape both keep working
// while it does. A reader who took the question away in the meantime
// has answered it: the page went up, which is never wrong, and
// nothing else here happens.
if (syncOffered !== op) return;
syncCovered = false;
syncDialog.close();
// The panel may be up behind the dialog, asking about this very
// position. Answering here answers it.
hideCatchup();
const stamp = snapshot();
if (!current(stamp)) return;
await goThere(catchup.adopt(op), stamp, activity);
});
syncKeep?.addEventListener("click", async () => {
const op = syncOffered;
// Same rule as Take: an answer is only given once the page it is
// given from has been recorded, and only if the question is still
// being asked by the time it has been.
const answer = await keepHere(op, () => syncOffered === op);
if (answer === "withdrawn" || syncOffered !== op) return;
if (answer === "unsent") {
syncSummary.textContent =
"This page could not be sent, so nothing was settled. Try again in a moment.";
return;
}
syncCovered = false;
syncDialog.close();
hideCatchup();
});
// ------------------------------------------------------ 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));
}
// A conflicted annotation is a disagreement about the reader's own
// words, and the page holding the book is where it is settled. That is
// true of an online reader too: it drains the same queue and raises the
// same conflicts, and leaving them for the installed app would strand
// them on a device that may never open this book again.
//
// It is the only answer an annotation gets. The stuck panel deliberately
// does not offer one, because discarding an annotation's outbox row
// would leave the annotation itself marked unsaved with nothing left to
// save it; both answers here settle the annotation store as well.
async function resolveStoredAnnotationConflicts() {
if (!cfg.offline && !offlineContext) return;
if (!offlineAccount) 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);
}
}
// Every drain ends by asking whether it raised an annotation conflict,
// and two drains can overlap. One at a time, then: `window.confirm` is
// modal, and a second run would queue a dialog about an annotation the
// first run is already settling.
let resolvingConflicts = null;
function resolveAnnotationConflicts() {
if (resolvingConflicts) return resolvingConflicts;
resolvingConflicts = resolveStoredAnnotationConflicts()
.catch(error => { console.warn("An annotation conflict is unresolved:", error); })
.finally(() => { resolvingConflicts = null; });
return resolvingConflicts;
}
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));
});
// ---------------------------------------------------- reading prompt
//
// The account keeps a prompt about the page in front of it (see
// reader-prompt.js), and this is the button that fills it in and puts
// it on the clipboard. An account that wrote a prompt sees the control;
// a selection, when present, replaces the page's short fallback excerpt.
//
// Where the prompt comes from depends on where this page is served. The
// same-origin page carries it in its config, because the server knew
// the account when it rendered it. The detached reader origin has no
// session, so it asks /v1/me once the bearer token is in hand. The
// offline reader can ask nobody, so every template that arrives is kept
// in localStorage against the account it belongs to, and that copy is
// what an offline reader uses.
const PROMPT_CACHE_PREFIX = "liseur.reader.prompt.";
const PROMPT_MAX_TEXT = 4096;
let promptTemplate = cfg.promptTemplate || "";
let promptSelection = null;
let promptButtonPreservingSelection = false;
function promptCacheKey(account) {
return account ? PROMPT_CACHE_PREFIX + account : "";
}
function recallPromptTemplate(account) {
const key = promptCacheKey(account);
if (!key) return "";
try {
return localStorage.getItem(key) || "";
} catch (err) {
return "";
}
}
function rememberPromptTemplate(account, template) {
const key = promptCacheKey(account);
if (!key) return;
try {
if (template) localStorage.setItem(key, template);
else localStorage.removeItem(key);
} catch (err) {
/* private browsing: this page still has the template it was given */
}
}
function setPromptTemplate(next) {
promptTemplate = typeof next === "string" ? next : "";
updatePromptButton();
}
// bindPromptTemplate settles what this page will offer for an account.
// The same-origin page is rendered by a server that already knows the
// account, so its template is authoritative even when the account
// cleared it to an empty string — that empty value is written through,
// replacing any stale cache entry. An offline or detached page has no
// such answer of its own, so it falls back to whatever was cached until
// something says otherwise, and adopts a non-empty template at once so
// its button appears immediately rather than a second later.
function bindPromptTemplate(account) {
if (!account) return;
if (!cfg.offline && !cfg.detached) {
rememberPromptTemplate(account, cfg.promptTemplate);
setPromptTemplate(cfg.promptTemplate);
return;
}
if (cfg.promptTemplate) {
rememberPromptTemplate(account, cfg.promptTemplate);
setPromptTemplate(cfg.promptTemplate);
return;
}
setPromptTemplate(recallPromptTemplate(account));
}
// refreshPromptTemplate asks the server what the account keeps. Only
// the detached origin needs it — everywhere else the page was rendered
// by a server that knew the answer — and it is best-effort: a reader
// whose prompt cannot be fetched is a reader without a button, never an
// error on the page.
async function refreshPromptTemplate(account) {
if (cfg.offline || !cfg.detached) return;
try {
const response = await api("v1/me");
if (!response.ok || !auth.responseCurrent(response)) return;
const body = await response.json();
const template = typeof body?.reader_prompt_template === "string"
? body.reader_prompt_template
: "";
rememberPromptTemplate(account, template);
setPromptTemplate(template);
} catch (error) {
console.warn("The reading prompt could not be read:", error);
}
}
// oneName pulls a person's or a series' name out of whatever the
// package document put there. Readium keeps a contributor as a string,
// as an object with a name, as a language map, or as a list of any of
// those, and a prompt wants one readable string out of all of them.
function oneName(raw) {
if (!raw) return "";
if (typeof raw === "string") return raw.trim();
if (Array.isArray(raw)) return raw.map(oneName).filter(Boolean).join(", ");
if (typeof raw === "object") {
if (raw.name) return oneName(raw.name);
const values = Object.values(raw);
return values.length ? oneName(values[0]) : "";
}
return String(raw);
}
function bookAuthor() {
return oneName(view?.book?.metadata?.author);
}
function bookSeries() {
const metadata = view?.book?.metadata;
return oneName(metadata?.belongsTo?.series ?? metadata?.series);
}
// promptSelectionFromDocument is the annotation capture without the
// anchor: a prompt quotes the passage, it does not point at it, so a
// chapter the engine cannot make a CFI for still gives a usable
// selection.
function promptSelectionFromDocument(doc) {
const selection = doc.getSelection?.();
if (!selection || !selection.rangeCount || selection.isCollapsed) return null;
const text = selection.toString().trim();
if (!text) return null;
return { text: text.slice(0, PROMPT_MAX_TEXT), section: here?.section?.current };
}
// forgetPromptSelectionOutside drops a held passage once the reader has
// left the chapter it was in. The document it lives in is gone by then
// and nothing else would tell this page the quote is stale.
function forgetPromptSelectionOutside(location) {
if (!promptSelection) return;
const section = location.section || {};
if (promptSelection.section === undefined) return;
if (promptSelection.section === section.current) return;
promptSelection = null;
updatePromptButton();
}
function updatePromptButton() {
if (!promptButton) return;
const offer = !!promptTemplate;
if (offer && promptButton.hidden) revealChrome();
promptButton.hidden = !offer;
}
function promptPageText() {
const contents = view?.renderer?.getContents?.() || [];
const text = contents
.map(({ doc }) => doc?.body?.textContent || "")
.join(" ")
.trim();
return text ? text.split(/\s+/).slice(0, 10).join(" ") : "";
}
// promptValues is where the reader is, in the words the template uses.
// Everything here is already on the page: the same percentage, page and
// chapter the footer draws.
function promptValues() {
const location = here || {};
const pages = readerPagePair(location);
return {
title: bookTitle(),
author: bookAuthor(),
series: bookSeries(),
chapter: chapterLabel(location),
page: pages ? String(pages.page) : "",
pages: pages ? String(pages.total) : "",
percent: finite(location.fraction)
? String(Math.round(location.fraction * 100))
: "",
text: promptSelection?.text || promptPageText(),
};
}
// writeClipboard puts the prompt on the clipboard by whatever means
// this browser allows, and says whether anything worked.
//
// The passage was selected inside the chapter's own frame, so at the
// moment of the press the reading page may hold neither the selection
// nor — in Firefox — the focus that a clipboard write is judged
// against. One refusal is therefore not an answer: the write is tried,
// tried again with this document focused, and finally made the old way,
// through a textarea of our own and execCommand. Only a browser that
// refuses all three has really refused, and that is what the fallback
// dialog is for.
async function writeClipboard(text) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
window.focus();
await navigator.clipboard.writeText(text);
return true;
} catch {
// Nothing to say here: the old path is tried next, and the
// dialog after that.
}
}
}
return copyTheOldWay(text);
}
function copyTheOldWay(text) {
if (typeof document.execCommand !== "function") return false;
const box = document.createElement("textarea");
box.value = text;
box.setAttribute("readonly", "");
box.style.cssText =
"position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0";
document.body.appendChild(box);
const wasFocused = document.activeElement;
let copied = false;
try {
box.focus({ preventScroll: true });
box.setSelectionRange(0, text.length);
copied = document.execCommand("copy");
} catch {
copied = false;
}
box.remove();
try {
wasFocused?.focus?.({ preventScroll: true });
} catch {
// A page that cannot take its focus back has still copied.
}
return copied;
}
async function copyPrompt() {
if (!promptTemplate) return;
const prompt = fillPrompt(promptTemplate, promptValues());
if (await writeClipboard(prompt)) {
flash("Prompt copied.");
return;
}
// The prompt is still worth having, so it is handed over to be
// copied by hand.
showPromptFallback(prompt);
}
// The dialog hands the text over, and the bar steps aside for it (see
// the stylesheet): a box a reader has to read is no use with the top of
// it under the chrome. Focus goes to the Copy button rather than to the
// text, because a press there is a fresh gesture in this document —
// which is usually what the refused write was missing — and because a
// focused text box invites a phone to raise its keyboard over the
// dialog. The text is selected all the same, so a copy by hand is one
// gesture either way.
function showPromptFallback(prompt) {
if (!promptFallback || !promptFallbackText) {
say("This browser would not copy the prompt.", true);
return;
}
promptFallbackText.value = prompt;
promptFallback.showModal();
try {
promptFallbackText.setSelectionRange(0, prompt.length);
} catch {
// Selecting is a convenience; the text is there to copy regardless.
}
if (promptFallbackCopy) promptFallbackCopy.focus();
else {
promptFallbackText.focus();
promptFallbackText.select();
}
}
promptFallbackCopy?.addEventListener("click", () => {
const text = promptFallbackText?.value || "";
if (!text) return;
writeClipboard(text).then(copied => {
if (copied) {
promptFallback?.close();
flash("Prompt copied.");
return;
}
// Still refused. The reader's own copy gesture is the way out, so
// the text is put under it, selected and focused.
promptFallbackText.focus();
promptFallbackText.select();
}).catch(() => {
promptFallbackText.focus();
promptFallbackText.select();
});
});
function wirePromptSelection(doc) {
const update = () => {
const next = promptSelectionFromDocument(doc);
// A pointer already on the button is a reader reaching for it: the
// selection they are about to quote must outlive the press that
// collapses it.
if (!next && promptButtonPreservingSelection) return;
promptSelection = next;
updatePromptButton();
};
doc.addEventListener("selectionchange", update);
doc.addEventListener("mouseup", () => setTimeout(update, 0));
doc.addEventListener("touchend", () => setTimeout(update, 0), { passive: true });
}
promptButton?.addEventListener("pointerdown", () => {
promptButtonPreservingSelection = true;
});
promptButton?.addEventListener("click", () => {
copyPrompt().catch(error =>
say(error.message || "The prompt could not be copied.", true))
.finally(() => {
promptButtonPreservingSelection = false;
});
});
// 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 withAnchor(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 withAnchor({
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,
},
});
}
// withAnchor describes the passage on screen well enough for another
// client to find it again, when it can. A CFI says the same thing but
// only to a reader that speaks CFI; the app on a phone does not, and a
// percentage lands it in the wrong paragraph. When there is no anchor
// to be had — nothing visible yet, a quote that appears twice in its
// block, a locator that would grow past what the server accepts — the
// locator goes as it was, and the resource and its progression are
// still a good place to reopen at.
function withAnchor(locator) {
try {
return markLocator(locator, view.visibleAnchor(), locatorLimitBytes);
} catch (err) {
return locator;
}
}
// 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. Transient failures retain the operation for
// the next foreground retry, reconnect or page turn.
//
// 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. A refusal is never an acknowledgement.
let retryOp = null;
let positionInFlight = null;
let positionAgain = false;
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 (positionInFlight) {
positionAgain = true;
return positionInFlight;
}
const activity = activityGeneration;
positionInFlight = pushPosition().finally(() => {
positionInFlight = null;
const again = positionAgain;
positionAgain = false;
if (again && readingDirty) push();
else if (activity !== activityGeneration && readingDirty) schedulePush();
});
return positionInFlight;
}
// recoverRefusal answers the one refusal a position can recover from
// by itself.
//
// A batch refused for the size of its locator stored nothing, so the
// op id is still free — and `docs/integrating.md` names resending the
// same op without its locator as the recovery. That is the protocol's
// deliberate exception to replaying the same bytes: everything else
// about the op, its id included, stays as it was, so the reading is
// recorded once and under the identity it was first given. The
// progression alone still says where the reader is.
async function recoverRefusal(resp, op) {
const body = await resp.json().catch(() => null);
if (body?.code !== "locator_too_large") return;
if (Number.isFinite(body.limit) && body.limit > 0) locatorLimitBytes = body.limit;
if (!op.locator || retryOp?.op !== op) return;
const { locator: _dropped, ...bare } = op;
// Left in retryOp under the same key, so the next push — a page turn,
// a foreground retry, the flush on the way out — sends this reduced
// copy rather than building the refused one again. A reader who has
// since moved on gets a different key and a new op, which is right:
// the newer page is the one worth recording.
retryOp = { key: retryOp.key, op: bare };
}
async function pushPosition() {
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 edition = editionSHA();
// The retry key is what makes a repeat of the same position replay the
// same bytes rather than mint a new op. The edition belongs in it: the
// same chapter fraction in a re-uploaded file is a different place,
// and reusing the old op for it would file the new reading under the
// old bytes.
const key =
(locator.locations.fragments[0] || "") +
"@" +
locator.locations.totalProgression +
"@" +
edition;
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,
// Named only when it is known and agreed. An absent field is
// how every op read before this, and the other client treats
// it as an edition nobody vouched for.
...(edition ? { edition_sha: edition } : {}),
};
retryOp = { key, op };
catchup.wrote(op);
if (cfg.offline || (durableSync && offlineContext)) {
try {
await saveReadingPosition({
...offlineContext,
bookID: cfg.bookID,
workID,
op,
requireSnapshot: cfg.offline,
});
catchup.local(op);
if (cfg.offline) notifyOfflineChange();
else if (leaving) {
// The queue is the guarantee, but an unload is the last moment
// this page can speak and another device is probably waiting.
// The queued copy carries the same bytes under the same op id,
// so a later delivery is a duplicate rather than a second page.
api("v1/ops", {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify({ ops: [op] }),
}).catch(() => {});
} else scheduleSend();
if (retryOp?.op === op) {
retryOp = null;
if (locatorFor(here)?.locations.fragments[0] === locator.locations.fragments[0] &&
here.fraction === locator.locations.totalProgression) readingDirty = false;
}
} catch (err) {
// An expired local account cannot be queued into. The reader
// keeps reading; the credential layer is what says so.
if (!cfg.offline && err?.code === "auth") {
offlineContext = null;
readingCoordinator?.stop();
readingCoordinator = null;
releaseOfflineReader?.();
releaseOfflineReader = null;
sessionOwner = false;
return;
}
say(err.message || "This reading position could not be saved on this device.", 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] }),
});
try {
const resp = await request;
stamp.identity = auth.responseIdentity(resp) || stamp.identity;
if (!resp.ok) {
if (current(stamp) && auth.responseCurrent(resp)) await recoverRefusal(resp, op);
return;
}
if (!current(stamp) || !auth.responseCurrent(resp)) return;
const out = await resp.json().catch(() => null);
if (!current(stamp) || !auth.responseCurrent(resp)) return;
const result = out?.results?.[0];
if (result?.op_id === op.op_id && result.status === "conflict") {
if (retryOp?.op === op) retryOp = null;
say("This position changed while it was syncing. Retrying with a new operation.", true);
return;
}
if (!positionAcknowledged(out, op)) return;
catchup.settled(op);
// 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 */
}
}
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();
// Local durability need not wait for the network debounce.
pending = setTimeout(push, cfg.offline || offlineContext ? 0 : 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 || sessionOwner ? offlineCheckpoint : null,
});
offlineCheckpoint = null;
checkpointSession();
}
// A sitting is checkpointed as it goes, so a browser that is killed
// mid-page still leaves the reading behind: the next open finds the
// checkpoint and carries on inside the same sitting instead of
// discarding the minutes before the crash. Only the page holding the
// book's reader claim writes one, because a checkpoint names one
// sitting and two pages finalizing under that name is a refused
// session, not a longer one.
function checkpointSession() {
if (!session || !offlineContext || !(cfg.offline || sessionOwner)) return;
const checkpoint = session.checkpoint();
if (!checkpoint) return;
saveOfflineSessionCheckpoint({
...offlineContext,
bookID: cfg.bookID,
checkpoint,
}).catch(error => say(
error.message || "This reading progress could not be saved on this device.",
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();
}
}
// True once the page is going away. Only then does a finished sitting go
// out directly as well as durably: an unload is the last moment another
// device could hear about this sitting promptly. On any other path the
// queue is both the guarantee and prompt enough, and posting the same
// sitting twice at once races itself at the server.
let leaving = false;
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 || offlineContext) {
try {
if (payload) {
await finishOfflineSession({
...offlineContext,
bookID: cfg.bookID,
payload,
});
// Only now may the page speak directly. The durable write is
// what spends the session id: it removes the checkpoint, so no
// later page can resume this sitting and close it again with a
// different ending. Sending first and queueing afterwards lost
// that race on an unload — the request went, the IndexedDB
// write did not finish, and the next open finalized the same
// id with different bytes, which the server refuses forever as
// a reused id.
//
// An unload that kills the page before this point sent nothing
// either, so the checkpoint that survives is still true.
if (!cfg.offline && leaving) {
unsent = [payload];
pushSession(true);
}
} else {
await clearOfflineSessionCheckpoint({
...offlineContext,
sessionID,
bookID: cfg.bookID,
});
}
if (cfg.offline) notifyOfflineChange();
else readingCoordinator?.trigger();
} catch (error) {
say(error.message || "This reading session could not be saved on this device.", 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: async (sent) => {
unsent = unsent.filter((p) => !sent.includes(p));
// The queued copy is what a later drain would replay. Settling
// it here keeps the same sitting from going out twice.
if (offlineContext) {
for (const item of sent) {
await removeOfflineOutbox({
...offlineContext, kind: "session", id: item.session_id,
}).catch(() => {});
}
}
},
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();
push();
pushSession();
readingCoordinator?.trigger();
if (ready) startLive();
beginSession();
if (here && !finite(here.fraction)) {
fractionRetryAttempt = 0;
scheduleFractionRetry();
}
});
window.addEventListener("pageshow", () => { leaving = false; });
window.addEventListener("pagehide", () => {
lifecycle++;
leaving = true;
live.stop();
refreshes.stop();
catchup.hide();
hideCatchup();
cancelScheduledPush();
push();
endSession();
});
window.addEventListener("online", () => {
if (document.hidden) return;
push();
pushSession();
readingCoordinator?.trigger();
if (ready) startLive();
});
setInterval(() => {
if (document.hidden || navigator.onLine === false) return;
push();
pushSession();
readingCoordinator?.trigger();
}, 30000);
// The ladder itself is in reader-restore.js, which knows nothing about
// this page. All that is decided here is what the open publication can
// answer: which edition it is, what its spine is called, and whether a
// CFI names a chapter in it.
function restoreView() {
return {
editionSHA: editionSHA(),
sections: (view.book && view.book.sections) || [],
resolveKey: (key) => view.resources?.resolveKey(key) ?? null,
cfiResolves: (cfi) => {
try {
const resolved = view.resolveNavigation(cfi);
return (
resolved != null &&
typeof resolved.index === "number" &&
!!view.book.sections[resolved.index]
);
} catch (err) {
return false;
}
},
};
}
function startCandidates(op) {
return op ? restoreCandidates(op, restoreView()) : [];
}
// What the presenter needs to turn an op into a page a reader can
// check: the same facts the restore ladder stands on, plus the position
// table and this page's own anchor. Before the book has opened there is
// only the table, which is enough for a percentage and honest about the
// rest.
function placeView() {
if (!view) return { table: positions };
return { ...restoreView(), table: positions, anchor: view.visibleAnchor() };
}
// ------------------------------------------------------ appearance
// Reader appearance, Komga-style: theme, font, size, spacing, and margins.
// 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. Typography defaults to "what the publisher said":
// a fresh reader sets the book in the face it was shipped in, and every
// override below exists only once the user asks for it.
//
// Colour is the exception, and deliberately so. The publisher's own
// palette is whatever that EPUB's stylesheet happens to declare, which
// across a shelf means every book opening a different colour — and a
// book that declares nothing inherits the page, which is not a decision
// anybody made. So a fresh reader opens Light, matching the web UI's
// own default, and Publisher stays one radio away for the books whose
// design is the point.
const SETTINGS_KEY = "liseur.reader.settings";
// Compact screens use narrower margins by default, but they still open with
// the reader bar visible. A saved reader preference wins when there is one.
const COMPACT_READER = /Android/i.test(navigator.userAgent) ||
window.matchMedia("(max-width: 700px)").matches;
const SETTINGS_DEFAULTS = Object.freeze({
theme: "light",
font: "publisher",
size: 120,
spacing: "0",
justify: false,
hyphenate: false,
columns: "auto",
margin: COMPACT_READER ? "narrow" : "normal",
autohide: false,
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. "positions-chapter" shows
// how many Readium positions remain in the current chapter.
const FOOTER_MODES = ["chapter", "positions-chapter", "time-chapter", "time-book", "empty"];
const THEMES = {
light: {
bg: "#ffffff", fg: "#1b1b1f", link: "#1a63c4", scheme: "light",
selectionBg: "#cfe3ff", selectionFg: "#1b1b1f",
},
sepia: {
bg: "#f6ecd9", fg: "#5b4636", link: "#8a5a2b", scheme: "light",
selectionBg: "#d8c2a0", selectionFg: "#4a392c",
},
dark: {
bg: "#202124", fg: "#cfcfd4", link: "#8ab4f8", scheme: "dark",
selectionBg: "#4f6fbe", selectionFg: "#f5f7ff",
},
"tokyo-night": {
bg: "#1a1b26",
fg: "#c0caf5",
link: "#7aa2f7",
scheme: "dark",
selectionBg: "#445c9b",
selectionFg: "#eef2ff",
},
"rose-pine": {
bg: "#191724",
fg: "#e0def4",
link: "#c4a7e7",
scheme: "dark",
selectionBg: "#5c4a88",
selectionFg: "#f7f4ff",
},
black: {
bg: "#000000", fg: "#ababae", link: "#7aa2d8", scheme: "dark",
selectionBg: "#375f9d", selectionFg: "#f5f7ff",
},
};
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));
// Scroll mode was removed. Ignore the value saved by an older reader
// instead of carrying a setting no control can now change.
if (stored && typeof stored === "object") delete stored.flow;
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}; --liseur-selection-bg: ${theme.selectionBg}; --liseur-selection-fg: ${theme.selectionFg}; }`,
`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; }`,
"body::selection, body *::selection { background-color: var(--liseur-selection-bg) !important; color: var(--liseur-selection-fg) !important; }",
"body::-moz-selection, body *::-moz-selection { background-color: var(--liseur-selection-bg) !important; color: var(--liseur-selection-fg) !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.readerAutohide = String(!!settings.autohide);
document.body.dataset.readerFooter = FOOTER_MODES.includes(settings.footer)
? settings.footer
: SETTINGS_DEFAULTS.footer;
document.body.dataset.readerMargin = settings.margin in MARGINS
? settings.margin
: SETTINGS_DEFAULTS.margin;
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");
const sizeDown = document.getElementById("reader-size-down");
const sizeUp = document.getElementById("reader-size-up");
function sizeField() {
return settingsForm?.elements?.namedItem("size");
}
function clampSize(size) {
const field = sizeField();
const min = Number(field?.min || SETTINGS_DEFAULTS.size);
const max = Number(field?.max || SETTINGS_DEFAULTS.size);
if (!Number.isFinite(size)) return SETTINGS_DEFAULTS.size;
return Math.min(Math.max(size, min), max);
}
function sizeStep() {
const step = Number(sizeField()?.step || 5);
return Number.isFinite(step) && step > 0 ? step : 5;
}
function bumpSize(direction) {
const field = sizeField();
if (!field) return;
const next = clampSize((Number(field.value) || settings.size) + direction * sizeStep());
field.value = String(next);
field.dispatchEvent(new Event("input", { bubbles: true }));
}
function syncSizeControls() {
if (sizeOut) sizeOut.textContent = settings.size + "%";
if (sizeDown) sizeDown.disabled = settings.size <= Number(sizeField()?.min || 0);
if (sizeUp) sizeUp.disabled = settings.size >= Number(sizeField()?.max || Infinity);
}
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]);
}
syncSizeControls();
}
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: clampSize(Number(data.get("size")) || SETTINGS_DEFAULTS.size),
spacing: String(data.get("spacing") || SETTINGS_DEFAULTS.spacing),
justify: data.has("justify"),
hyphenate: data.has("hyphenate"),
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),
};
syncSizeControls();
}
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;
}
});
sizeDown?.addEventListener("click", () => bumpSize(-1));
sizeUp?.addEventListener("click", () => bumpSize(1));
}
// -------------------------------------------------------- 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";
const fullscreenRoot = document.documentElement;
const enterFullscreen = fullscreenRoot.requestFullscreen || fullscreenRoot.webkitRequestFullscreen;
const exitFullscreen = document.exitFullscreen || document.webkitExitFullscreen;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement;
const fullscreenAvailable = !!(enterFullscreen && exitFullscreen) &&
(document.fullscreenEnabled ?? document.webkitFullscreenEnabled ?? true);
async function toggleFullscreen() {
if (!fullscreenAvailable) {
flash("Full screen is unavailable. Add Liseur to your Home Screen for a full-screen reader.");
return;
}
try {
// Older iPad Safari exposes prefixed methods returning void, not a Promise.
if (fullscreenElement()) await exitFullscreen.call(document);
else await enterFullscreen.call(fullscreenRoot);
} catch (err) {
flash("This browser would not switch to full screen.");
}
}
if (fullscreenBtn) {
fullscreenBtn.hidden = !fullscreenAvailable;
fullscreenBtn.addEventListener("click", toggleFullscreen);
const updateFullscreen = () => {
const p = fullscreenBtn.querySelector("path");
if (fullscreenElement()) {
p.setAttribute("d", MINIMIZE_PATH);
fullscreenBtn.title = "Exit full screen (f)";
} else {
p.setAttribute("d", MAXIMIZE_PATH);
fullscreenBtn.title = "Full screen (f)";
}
};
document.addEventListener("fullscreenchange", updateFullscreen);
document.addEventListener("webkitfullscreenchange", updateFullscreen);
}
// ---------------------------------------------- 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;
const TAP_MAX_MS = 500;
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, startedAt: 0, spoiled: true, hadSelection: false };
}
function trackGesture(gesture, e) {
if (gesture.id !== e.pointerId) return;
if (Math.abs(e.clientX - gesture.x) > TAP_SLOP_PX ||
Math.abs(e.clientY - gesture.y) > TAP_SLOP_PX) gesture.spoiled = true;
}
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) => {
trackGesture(gesture, 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,
startedAt: performance.now(),
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 });
doc.addEventListener("contextmenu", () => { gesture.spoiled = true; }, { 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 (performance.now() - gesture.startedAt > TAP_MAX_MS) gesture.spoiled = true;
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;
const percent = Math.round(fraction * 100);
progressBar.dataset.readerProgress = String(percent);
progressText.textContent = percent + "%";
}
// 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);
forgetPromptSelectionOutside(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 pair = readerPagePair(location);
return pair ? pair.page + " of " + pair.total : null;
}
// readerPagePair is the same answer with the two numbers kept apart,
// for a caller that wants the page without the sentence around it.
function readerPagePair(location) {
const section = location.section || {};
const n = pageAt(positions, section.current, location.sectionFraction);
if (n) return { page: n, total: positions.total };
const loc = location.location || {};
if (finite(loc.current) && finite(loc.total) && loc.total > 0) {
return {
page: Math.min(Math.max(1, Math.floor(loc.current) + 1), loc.total),
total: loc.total,
};
}
return null;
}
// chapterLabel is the chapter title the book itself gives this spot,
// falling back to a plain count when the navigation has no entry
// covering it.
function chapterLabel(location) {
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 "";
}
// 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 "positions-chapter": {
// Show how many Readium positions remain in the current chapter.
if (!chapters || !positions || chapters.chapters.length === 0) return "";
const section = location.section || {};
const currentPage = pageAt(positions, section.current, location.sectionFraction);
if (!currentPage) return "";
const chapter = chapterForLocation(chapters.chapters, chapters.chapterIndexByResource, section.current, currentPage);
const pagesLeft = pagesLeftInChapter(chapter, currentPage);
if (pagesLeft === null) return "";
if (pagesLeft === 0) return "Last page in chapter";
return pagesLeft + (pagesLeft === 1 ? " page" : " pages") + " left in chapter";
}
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:
return chapterLabel(location);
}
}
// 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;
const loading = startChapterLoading(`Loading page ${page}…`);
try {
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(() => {});
}
} finally {
loading.stop();
}
}
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) {
const loading = startChapterLoading(`Loading ${pct}%…`);
await view.goToFraction(pct / 100)
.catch(() => {})
.finally(loading.stop);
}
} 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);
});
let tocWarmToken = 0;
const warmTOCHref = (target) => {
const a = target && target.closest && target.closest("a[data-href]");
if (!a || !view?.resources?.warm) return;
const href = a.dataset.href?.split("#")[0];
if (!href) return;
const token = ++tocWarmToken;
window.setTimeout(() => {
if (token !== tocWarmToken) return;
view.resources.warm(href);
}, 150);
};
tocList.addEventListener("pointerenter", (e) => warmTOCHref(e.target), true);
tocList.addEventListener("touchstart", (e) => warmTOCHref(e.target), { passive: true, capture: true });
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);
const title = a.textContent?.trim();
if (view) {
const loading = startChapterLoading(title ? `Loading ${title}…` : "Loading chapter…");
view.goTo(a.dataset.href)
.catch(() => { say(`"${title || "That chapter"}" could not be opened. Try again.`, true); })
.finally(loading.stop);
}
});
function turn(direction) {
if (!noteNavigation()) return undefined;
const chapterMessage = direction > 0 ? "Loading next chapter…" : "Loading previous chapter…";
// A chapter turn waits as long as a page turn before it says anything.
// The frame pool keeps the neighbouring chapters built, so most chapter
// turns are as quick as a page turn, and a loader shown for one of them
// would only look like a delay.
const loading = startChapterLoading(isChapterEdge(direction) ? chapterMessage : "Loading page…", 80);
const beforeSection = view.lastLocation?.section?.current;
const promise = direction > 0 ? view.goRight() : view.goLeft();
const finished = () => {
if (view.lastLocation?.section?.current !== beforeSection) {
loading.relabel(chapterMessage);
}
loading.stop();
};
if (promise && typeof promise.then === "function") {
promise.then(finished, finished);
} else {
finished();
}
return promise;
}
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,
startedAt: performance.now(),
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("pointermove", (e) => trackGesture(stageGesture, e), { passive: true });
stageArea.addEventListener("contextmenu", () => { stageGesture.spoiled = true; }, { 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 (performance.now() - stageGesture.startedAt > TAP_MAX_MS) 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();
void dismissCatchup();
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;
// A modal dialog owns the keyboard while it is up: the platform
// handles Escape and focus, and a page turn behind it would move the
// book out from under the question being asked.
if (syncDialog && syncDialog.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 "s":
e.preventDefault();
revealChrome();
void askBookSync();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
case "z":
e.preventDefault();
toggleChrome();
break;
case "Home":
if (noteNavigation()) {
const loading = startChapterLoading("Loading beginning…");
view.goToFraction(0).catch(() => {}).finally(loading.stop);
}
break;
case "End":
if (noteNavigation()) {
const loading = startChapterLoading("Loading end…");
view.goToFraction(1).catch(() => {}).finally(loading.stop);
}
break;
}
}
document.addEventListener("keydown", handleKeys);
window.addEventListener("beforeunload", () => {
clearTimeout(pending);
leaving = true;
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);
if (annotationsEnabled) wireSelection(e.detail.doc);
wirePromptSelection(e.detail.doc);
});
view.addEventListener("link", (e) => {
e.preventDefault();
if (!noteNavigation()) {
return;
}
const href = e.detail?.href;
if (!href) return;
const loading = startChapterLoading("Loading chapter…");
view.goTo(href)
.catch(() => { say("That link could not be opened.", true); })
.finally(loading.stop);
});
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;
// Offline the catalog cannot be asked, but it already was: a
// snapshot is only stored ready once its publication URLs were
// checked against the digest the catalog named at download time.
// That is the same agreement resolveWork() reaches online, made
// earlier, so a position read on a plane still names its edition.
catalogEditionSHA = local.digest || "";
offlineAccount = local.account;
bindPromptTemplate(offlineAccount);
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);
chapters = buildChapters(view.book.toc, view.book.sections, positions, view.book.packageHref);
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);
// A locally queued wire op omits device_id; the credential supplies it
// on upload. Its echo after a reload is still our opening baseline.
const stamped = op && { ...op, device_id: op.device_id || offlineSnapshot.deviceID };
catchup.baseline(stamped);
catchup.local(stamped, false);
}
if (!cfg.offline) try {
workID = await resolveWork();
const identity = auth.identity();
catchup.bind(identity?.account, workID, identity?.device);
await prepareReadingSync(identity);
// A checkpoint names one sitting, so only the page that can claim
// this book keeps one. A second tab is refused the claim and reads
// on with a fresh sitting, which is what it did before.
if (offlineContext) {
releaseOfflineReader = await claimOfflineReader(offlineContext, cfg.bookID)
.catch(() => null);
if (releaseOfflineReader) {
sessionOwner = true;
offlineCheckpoint = await getOfflineSessionCheckpoint({
partition: offlinePartition, account: offlineAccount, bookID: cfg.bookID,
}).catch(() => null);
}
}
// What this browser last said, read back from its own disk. A page
// turn the server never acknowledged is still where the reader is,
// and it is still owed.
const stored = offlineContext
? await readingState({ ...offlineContext, bookID: cfg.bookID }).catch(() => null)
: null;
const queued = offlineContext
? await listOfflineOutbox({
...offlineContext, kind: "position", state: null,
}).catch(() => [])
: [];
const dirty = queued.some(record => record.deviceID === offlineContext?.deviceID &&
record.bookID === cfg.bookID);
const result = await lastPosition();
const remote = result.ok ? result.op : null;
const baseline = stored ? stored.baseline : remote;
catchup.baseline(baseline);
catchup.local(stored?.local || null, dirty);
const { decision } = reconcileReadingState({
local: stored?.local || null, remote, baseline, localDirty: dirty,
});
// A conflict opens where this reader was, never where the other
// device went: the trip is offered, not taken for them.
op = decision === "push" || decision === "conflict"
? stored?.local || remote : remote || stored?.local || null;
if (!stored && remote && offlineContext) {
await agreeReadingBaseline({
...offlineContext, bookID: cfg.bookID, workID, baseline: remote,
}).catch(() => {});
}
catchup.observe(remote);
} 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();
if (cfg.offline) prepareOfflineSync();
if (!cfg.offline && ready && !document.hidden) startLive();
if (document.hidden) catchup.hide();
if (!syncExpired) say("");
// Opening the book is a moment where a disagreement can be raised
// without taking the page out from under anybody.
if (!document.hidden) {
catchup.present();
showCatchup();
}
if (readingCoordinator) readingCoordinator.trigger();
} catch (err) {
releaseOfflineReader?.();
say((err && err.message) || "this book could not be opened", true);
}
})();