import "foliate-js/view.js"; import { Alpine } from "../alpine"; import { loadSettings, saveSettings } from "./settings-manager"; import { getToken } from "../storage"; const FONT_MAP: Record = { literata: '"Literata"', crimson: '"Crimson Pro"', "source-serif": '"Source Serif 4"', "eb-garamond": '"EB Garamond"', libertinus: '"Libertinus Serif"', "noto-serif": '"Noto Serif"', "charis-sil": '"Charis SIL"', "ibm-plex": '"IBM Plex Serif"', }; const getCSS = ({ fontFamily, fontSize, lineHeight, justify, hyphenate, }: { fontFamily: string; fontSize: number; lineHeight: number; justify: boolean; hyphenate: boolean; }) => { const el = document.getElementById("reader-viewport"); const style = getComputedStyle(el ?? document.body); const bg = style.getPropertyValue("--reader-bg").trim() || "#fafafa"; const text = style.getPropertyValue("--reader-text").trim() || "#1a1a1a"; const link = style.getPropertyValue("--reader-link").trim() || "#0066cc"; const selection = style.getPropertyValue("--reader-selection").trim() || "#b3d9ff"; const font = FONT_MAP[fontFamily] ?? '"Literata"'; return ` @namespace epub "http://www.idpf.org/2007/ops"; html { color-scheme: light dark; background-color: ${bg}; color: ${text}; font-family: ${font}, serif; font-size: ${fontSize}px; line-height: ${lineHeight}; } ::selection { background-color: ${selection}; } a:link { color: ${link}; } @media (prefers-color-scheme: dark) { a:link { color: ${link}; } } p, li, blockquote, dd { line-height: ${lineHeight}; text-align: ${justify ? "justify" : "start"}; -webkit-hyphens: ${hyphenate ? "auto" : "manual"}; hyphens: ${hyphenate ? "auto" : "manual"}; -webkit-hyphenate-limit-before: 3; -webkit-hyphenate-limit-after: 2; -webkit-hyphenate-limit-lines: 2; hanging-punctuation: allow-end last; widows: 2; } [align="left"] { text-align: left; } [align="right"] { text-align: right; } [align="center"] { text-align: center; } [align="justify"] { text-align: justify; } pre { white-space: pre-wrap !important; } aside[epub|type~="endnote"], aside[epub|type~="footnote"], aside[epub|type~="note"], aside[epub|type~="rearnote"] { display: none; } img, svg, video { max-width: 100%; height: auto; display: block; margin: 0.5em auto; } p { orphans: 3; widows: 3; } blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid ${link}; font-style: italic; } a { color: ${link}; text-decoration: underline; } `; }; document.addEventListener("alpine:init", () => { Alpine.data("readerShell", () => ({ view: null as any, renderer: null as any, book: null as any, zoomPercent: 100, isFixedLayout: false, isPDF: false, interactionMode: "select" as string, magnifierEnabled: false, progressText: "", sliderValue: 0, settings: null as ReaderSettings | null, justify: true, hyphenate: true, tocOpen: false, settingsOpen: false, bookmarksOpen: false, navigatorOpen: false, tocItems: [] as any[], readingTheme: "light" as string, readingMode: "light" as string, readingFont: "literata" as string, fontSize: 16 as number, lineHeight: 1.6 as number, async initReader(config: { mediaItemId: string; fileUrl: string; formatGroup: string; readingDirection: string; mangaType: string; }) { this.settings = await loadSettings(); if (this.settings) { this.readingTheme = this.settings.reading_theme || "light"; this.readingFont = this.settings.reading_font || "literata"; this.fontSize = this.settings.font_size || 16; this.lineHeight = this.settings.line_height || 1.6; if (this.settings.reading_mode) { this.readingMode = this.settings.reading_mode; } else { this.readingMode = this.detectChromeDarkMode() ? "dark" : "light"; } } const viewport = document.getElementById("reader-viewport")!; viewport.classList.add(`theme-${this.readingTheme}`); if (this.readingMode === "dark") { viewport.classList.add("dark"); } this.view = document.getElementById("reader-view") as any; const resp = await fetch(config.fileUrl, { headers: { Authorization: `Bearer ${getToken()}` }, }); const blob = await resp.blob(); const fileName = new URL(config.fileUrl, window.location.origin).pathname; const file = new File([blob], fileName, { type: blob.type }); await this.view.open(file); this.renderer = this.view.renderer; this.book = this.view.book; this.isFixedLayout = this.view.isFixedLayout; if (this.isFixedLayout) { this.isPDF = (this.renderer as any).isPDF; this.renderer.addEventListener("zoom", () => { this.zoomPercent = this.renderer.zoomPercent; }); } else { this.renderer.setStyles?.(this.buildCSS()); } this.view.addEventListener("load", (e: any) => { const { doc } = e.detail; doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); }); this.view.addEventListener("relocate", (e: any) => { const { fraction, location, pageItem } = e.detail; const percent = new Intl.NumberFormat("en", { style: "percent", }).format(fraction); const loc = pageItem ? `Page ${pageItem.label}` : `Loc ${location.current}`; this.progressText = `${percent} · ${loc}`; this.sliderValue = fraction; const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider) { slider.value = fraction; slider.title = `${percent} · ${loc}`; } }); const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider && this.book.dir) { slider.dir = this.book.dir; } if (this.view.getSectionFractions) { const tickMarks = document.getElementById("tick-marks"); if (tickMarks) { for (const fraction of this.view.getSectionFractions()) { const option = document.createElement("option"); option.value = fraction; tickMarks.append(option); } } } document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); this.renderer.next(); }, zoomIn() { if (!this.isFixedLayout) return; const newScale = Math.min(10, this.renderer.currentScale * 1.2); this.renderer.setAttribute("zoom", newScale); this.zoomPercent = this.renderer.zoomPercent; }, zoomOut() { if (!this.isFixedLayout) return; const newScale = Math.max(0.1, this.renderer.currentScale / 1.2); this.renderer.setAttribute("zoom", newScale); this.zoomPercent = this.renderer.zoomPercent; }, resetZoom() { if (!this.isFixedLayout) return; this.renderer.resetZoom(); this.renderer.dragOffset = { x: 0, y: 0 }; this.zoomPercent = 100; }, toggleMagnifier() { if (!this.isFixedLayout) return; this.renderer.toggleMagnifier(); this.magnifierEnabled = this.renderer.zoomMagnifierEnabled; }, toggleInteractionMode() { if (!this.isFixedLayout) return; const current = this.renderer.getAttribute("interaction-mode") || "select"; const next = current === "select" ? "pan" : "select"; this.renderer.setAttribute("interaction-mode", next); this.interactionMode = next; }, goLeft() { this.view?.goLeft?.(); }, goRight() { this.view?.goRight?.(); }, nextPage() { this.view?.next?.(); }, previousPage() { this.view?.prev?.(); }, goToFraction(value: string) { this.view?.goToFraction?.(parseFloat(value)); }, toggleTOC() { this.tocOpen = !this.tocOpen; if (this.tocOpen && this.tocItems.length === 0 && this.book?.toc) { this.tocItems = this.flattenTOC(this.book.toc); } }, toggleSettings() { this.settingsOpen = !this.settingsOpen; }, toggleBookmarks() { this.bookmarksOpen = !this.bookmarksOpen; }, toggleNavigator() { this.navigatorOpen = !this.navigatorOpen; }, toggleWindowShade(panelEl: HTMLElement) { panelEl.classList.toggle("panel-collapsed"); }, flattenTOC(items: any[], depth = 0): any[] { const result: any[] = []; for (const item of items) { result.push({ ...item, depth }); if (item.subitems?.length) { result.push(...this.flattenTOC(item.subitems, depth + 1)); } } return result; }, goToTOCItem(item: any) { if (this.view && item.href) { this.view.goTo(item.href); this.tocOpen = false; } }, goToBookmarkTarget(cfi: string) { if (this.view && cfi) { this.view.goTo(cfi); this.bookmarksOpen = false; } }, applyTheme() { const viewport = document.getElementById("reader-viewport")!; viewport.className = "absolute inset-x-0 top-[52px] bottom-[52px]"; viewport.classList.add(`theme-${this.readingTheme}`); if (this.readingMode === "dark") { viewport.classList.add("dark"); } this.applyStyles(); saveSettings({ reading_theme: this.readingTheme, reading_mode: this.readingMode, }); }, toggleReadingMode() { this.readingMode = this.readingMode === "dark" ? "light" : "dark"; this.applyTheme(); }, detectChromeDarkMode(): boolean { const darkChromeThemes = [ "theme-tokyo-night", "theme-dracula", "theme-nord", "theme-monokai", "theme-one-dark", "theme-material", "theme-catppuccin", ]; return darkChromeThemes.some((t) => document.body.classList.contains(t)); }, applyFont() { this.applyStyles(); saveSettings({ reading_font: this.readingFont, font_size: this.fontSize, line_height: this.lineHeight, }); }, applyStyles() { if (!this.renderer?.setStyles) return; this.renderer.setStyles(this.buildCSS()); }, buildCSS() { return getCSS({ fontFamily: this.readingFont, fontSize: this.fontSize, lineHeight: this.lineHeight, justify: this.justify, hyphenate: this.hyphenate, }); }, async addBookmark() { const token = getToken(); if (!token || !this.view) return; const location = this.view.lastLocation; if (!location) return; try { await fetch("/readers/bookmarks", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ title: `Bookmark at ${this.progressText}`, position: JSON.stringify(location), }), }); } catch (_e) { /* ignore bookmark errors for now */ } }, handleKeydown(event: KeyboardEvent) { const k = event.key; if (k === "ArrowLeft" || k === "h") this.goLeft(); else if (k === "ArrowRight" || k === "l") this.goRight(); else if (k === "+" || k === "=") this.zoomIn(); else if (k === "-" || k === "_") this.zoomOut(); else if (k === "0") this.resetZoom(); else if (k === "Escape") { if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) { this.toggleMagnifier(); } } }, })); }); Alpine.start();