AetheriaLore/site/quartz/components/scripts/aetheriaEpubReader.inline.ts
2026-07-10 12:19:56 +01:00

205 lines
8.3 KiB
TypeScript

type ReaderSettings = {
version: number
cfi?: string
theme: "dark" | "light" | "sepia"
font: "serif" | "sans"
fontSize: number
lineHeight: number
flow: "paginated" | "scrolled-doc"
spread: "always" | "none"
}
declare global {
interface Window {
ePub?: (source: string) => any
JSZip?: unknown
}
}
const cookieName = "aetheria_tbp_reader"
const settingsVersion = 4
const defaults: ReaderSettings = {
version: settingsVersion,
theme: "dark",
font: "serif",
fontSize: 100,
lineHeight: 165,
flow: "paginated",
spread: "none",
}
function readSettings(): ReaderSettings {
try {
const raw = document.cookie.split("; ").find((entry) => entry.startsWith(`${cookieName}=`))?.split("=").slice(1).join("=")
const saved = raw ? JSON.parse(decodeURIComponent(raw)) : {}
return {
...defaults,
version: settingsVersion,
cfi: saved.version === settingsVersion && typeof saved.cfi === "string" ? saved.cfi : undefined,
theme: ["dark", "light", "sepia"].includes(saved.theme) ? saved.theme : defaults.theme,
font: ["serif", "sans"].includes(saved.font) ? saved.font : defaults.font,
fontSize: Number.isFinite(saved.fontSize) ? Math.min(150, Math.max(80, saved.fontSize)) : defaults.fontSize,
lineHeight: Number.isFinite(saved.lineHeight) ? Math.min(210, Math.max(130, saved.lineHeight)) : defaults.lineHeight,
flow: ["paginated", "scrolled-doc"].includes(saved.flow) ? saved.flow : defaults.flow,
spread: saved.version === settingsVersion && ["always", "none"].includes(saved.spread) ? saved.spread : defaults.spread,
}
} catch {
return { ...defaults }
}
}
function saveSettings(settings: ReaderSettings) {
document.cookie = `${cookieName}=${encodeURIComponent(JSON.stringify(settings))}; Max-Age=31536000; Path=/; SameSite=Lax`
}
function loadScript(src: string, ready: () => boolean): Promise<void> {
if (ready()) return Promise.resolve()
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null
const script = existing ?? Object.assign(document.createElement("script"), { src })
script.addEventListener("load", () => resolve(), { once: true })
script.addEventListener("error", () => reject(new Error(`Could not load ${src}`)), { once: true })
if (!existing) document.head.appendChild(script)
})
}
function chapterHrefFor(locationHref: string): string {
return locationHref.replace(/chapter-(\d+)-p\d+\.xhtml$/, "chapter-$1.xhtml")
}
async function initReader(root: HTMLElement) {
if (root.dataset.readerReady === "true") return
root.dataset.readerReady = "true"
const status = root.querySelector<HTMLElement>("[data-reader-status]")!
const progress = root.querySelector<HTMLElement>("[data-reader-progress]")!
const viewport = root.querySelector<HTMLElement>("[data-reader-viewport]")!
const settings = readSettings()
try {
await loadScript("/static/vendor/epubjs/jszip.min.js", () => Boolean(window.JSZip))
await loadScript("/static/vendor/epubjs/epub.min.js", () => Boolean(window.ePub))
if (!window.ePub) throw new Error("The EPUB renderer did not initialise")
const book = window.ePub(root.dataset.epubSrc!)
const rendition = book.renderTo(viewport, {
width: "100%",
height: "100%",
flow: settings.flow,
spread: settings.spread,
})
const themes = {
dark: { body: { color: "#e8edf2", background: "#11161b" }, a: { color: "#62bfff" } },
light: { body: { color: "#202124", background: "#f5f6f7" }, a: { color: "#00659c" } },
sepia: { body: { color: "#302a23", background: "#f2ead9" }, a: { color: "#865017" } },
}
Object.entries(themes).forEach(([name, rules]) => rendition.themes.register(name, rules))
function applyTypography() {
rendition.themes.select(settings.theme)
rendition.themes.override("font-family", settings.font === "serif" ? "Georgia, serif" : "Arial, sans-serif")
rendition.themes.override("font-size", `${settings.fontSize}%`)
rendition.themes.override("line-height", String(settings.lineHeight / 100))
}
function resizeRendition() {
const width = Math.max(320, Math.floor(viewport.clientWidth))
const height = Math.max(320, Math.floor(viewport.clientHeight))
if (rendition.manager && typeof rendition.resize === "function") rendition.resize(width, height)
}
function clampRenditionDom() {
viewport.style.width = "100%"
viewport.style.maxWidth = "100%"
viewport.style.overflow = "hidden"
}
async function displayLocation(target?: string) {
status.textContent = "Loading chapter"
await rendition.display(target)
clampRenditionDom()
resizeRendition()
clampRenditionDom()
status.textContent = "Ready"
}
root.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-reader-setting]").forEach((control) => {
const key = control.dataset.readerSetting as keyof ReaderSettings
if (key !== "chapter" && settings[key] !== undefined) control.value = String(settings[key])
})
applyTypography()
await displayLocation(settings.cfi)
status.textContent = settings.cfi ? "Position restored" : "Ready"
const chapterSelect = root.querySelector<HTMLSelectElement>('[data-reader-setting="chapter"]')!
const navigation = await book.loaded.navigation
chapterSelect.replaceChildren(...navigation.toc.map((item: any) => new Option(item.label.trim(), item.href)))
chapterSelect.addEventListener("change", () => void displayLocation(chapterSelect.value))
void book.ready.then(() => book.locations.generate(1200))
rendition.on("relocated", (location: any) => {
clampRenditionDom()
settings.cfi = location.start.cfi
saveSettings(settings)
const percentage = book.locations?.length() ? book.locations.percentageFromCfi(settings.cfi) : null
progress.textContent = percentage == null ? "Position saved" : `${Math.round(percentage * 100)}% - position saved`
if (location.start.href) chapterSelect.value = chapterHrefFor(location.start.href)
})
const mutationObserver = new MutationObserver(() => clampRenditionDom())
mutationObserver.observe(viewport, { attributes: true, childList: true, subtree: true, attributeFilter: ["style"] })
root.querySelectorAll('[data-reader-action="previous"]').forEach((control) => {
control.addEventListener("click", () => {
void rendition.prev().then(() => clampRenditionDom())
})
})
root.querySelectorAll('[data-reader-action="next"]').forEach((control) => {
control.addEventListener("click", () => {
void rendition.next().then(() => clampRenditionDom())
})
})
root.querySelector('[data-reader-action="fullscreen"]')?.addEventListener("click", () => {
if (document.fullscreenElement) void document.exitFullscreen()
else void root.requestFullscreen()
})
root.addEventListener("keydown", (event) => {
if ((event.target as HTMLElement).matches("input, select")) return
if (event.key === "ArrowLeft") void rendition.prev().then(() => clampRenditionDom())
if (event.key === "ArrowRight") void rendition.next().then(() => clampRenditionDom())
})
root.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-reader-setting]").forEach((control) => {
if (control.dataset.readerSetting === "chapter") return
control.addEventListener("change", () => {
const key = control.dataset.readerSetting as keyof ReaderSettings
if (key === "fontSize" || key === "lineHeight") settings[key] = Number(control.value)
else (settings as any)[key] = control.value
if (key === "flow") {
rendition.flow(settings.flow)
void displayLocation(settings.cfi)
} else if (key === "spread") {
rendition.spread(settings.spread)
resizeRendition()
} else applyTypography()
saveSettings(settings)
})
})
window.addCleanup(() => {
mutationObserver.disconnect()
book.destroy()
})
} catch (error) {
root.dataset.readerReady = "false"
status.textContent = error instanceof Error ? error.message : "The reader could not open this book."
root.classList.add("reader-error")
}
}
document.addEventListener("nav", () => {
document.querySelectorAll<HTMLElement>(".aetheria-epub-reader").forEach((reader) => void initReader(reader))
})
export {}