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"', }; interface FontFile { family: string; url: string; weight: string; style: string; } const FONT_FILES: FontFile[] = [ { family: '"Literata"', url: "/static/fonts/literata/Literata-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Literata"', url: "/static/fonts/literata/Literata-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Crimson Pro"', url: "/static/fonts/crimson/CrimsonPro-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Crimson Pro"', url: "/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Source Serif 4"', url: "/static/fonts/source-serif/SourceSerif4-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Source Serif 4"', url: "/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"EB Garamond"', url: "/static/fonts/eb-garamond/EBGaramond-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"EB Garamond"', url: "/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Regular.woff2", weight: "400", style: "normal", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Bold.woff2", weight: "700", style: "normal", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Italic.woff2", weight: "400", style: "italic", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2", weight: "700", style: "italic", }, { family: '"Noto Serif"', url: "/static/fonts/noto-serif/NotoSerif-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Noto Serif"', url: "/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Regular.woff2", weight: "400", style: "normal", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Bold.woff2", weight: "700", style: "normal", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Italic.woff2", weight: "400", style: "italic", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2", weight: "700", style: "italic", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2", weight: "400", style: "normal", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2", weight: "700", style: "normal", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2", weight: "400", style: "italic", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2", weight: "700", style: "italic", }, ]; let fontBlobUrls: Record = {}; let fontsLoaded = false; async function loadFontBlobUrls(): Promise { if (fontsLoaded) return; const entries = await Promise.all( FONT_FILES.map(async (f) => { try { const resp = await fetch(f.url); const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); return { key: f.url, blobUrl }; } catch (_e) { return { key: f.url, blobUrl: f.url }; } }), ); for (const { key, blobUrl } of entries) { fontBlobUrls[key] = blobUrl; } fontsLoaded = true; } function buildFontFaceCSS(): string { return FONT_FILES.map((f) => { const src = fontBlobUrls[f.url] ?? f.url; return `@font-face { font-family: ${f.family}; src: url("${src}") format("woff2"); font-weight: ${f.weight}; font-style: ${f.style}; }`; }).join("\n"); } interface ThemeColors { fg: string; bg: string; link: string; } const THEME_COLORS: Record = { light: { light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" }, dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" }, }, paper: { light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" }, dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" }, }, sepia: { light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" }, dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" }, }, parchment: { light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" }, dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" }, }, warm: { light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" }, dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" }, }, candlelight: { light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" }, dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" }, }, azure: { light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" }, dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" }, }, sky: { light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" }, dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" }, }, arctic: { light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" }, dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" }, }, frost: { light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" }, dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" }, }, dusk: { light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" }, dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" }, }, sunset: { light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" }, dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" }, }, twilight: { light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" }, dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" }, }, forest: { light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" }, dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" }, }, moss: { light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" }, dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" }, }, slate: { light: { fg: "#334155", bg: "#f8fafc", link: "#475569" }, dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" }, }, oled: { light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" }, dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" }, }, solarized: { light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" }, dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" }, }, }; const getCSS = ({ fontFamily, fontSize, lineHeight, justify, hyphenate, themeName, themeMode, }: { fontFamily: string; fontSize: number; lineHeight: number; justify: boolean; hyphenate: boolean; themeName: string; themeMode: string; }) => { const themeSet = THEME_COLORS[themeName] ?? THEME_COLORS.light; const theme = themeMode === "dark" ? themeSet.dark : themeSet.light; const font = FONT_MAP[fontFamily] ?? '"Literata"'; const fontFamilyRule = fontFamily ? ` body { font-family: ${font}, serif !important; } body * { font-family: inherit !important; }` : ""; return ` ${buildFontFaceCSS()} @namespace epub "http://www.idpf.org/2007/ops"; html { color-scheme: light dark; font-size: ${fontSize}px; line-height: ${lineHeight}; } ${fontFamilyRule} html, body { color: ${theme.fg} !important; background-color: ${theme.bg} !important; } body * { color: inherit !important; border-color: currentColor !important; } a:any-link { color: ${theme.link} !important; text-decoration-color: color-mix(in srgb, currentColor 20%, transparent); text-underline-offset: .1em; } svg, img { background-color: transparent !important; } aside[epub|type~="footnote"] { display: none; } p, li, blockquote, dd { line-height: ${lineHeight} !important; text-align: ${justify ? "justify" : "start"} !important; hyphens: ${hyphenate ? "auto" : "none"}; } [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; tab-size: 2; } ::selection { background-color: rgba(128, 128, 128, 0.3); } img, svg, video { max-width: 100%; height: auto; } html { hanging-punctuation: allow-end last; orphans: 2; widows: 2; } `; }; 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[], mediaItemId: "" as string, saveTimeout: null as ReturnType | null, readingTheme: "light" as string, readingMode: "light" as string, readingFont: "literata" as string, fontSize: 16 as number, lineHeight: 1.6 as number, progressMode: "pages" as string, readingSpeedPpm: 0 as number, sectionFractionsArr: [] as number[], lastRelocateDetail: null as { fraction: number; location: { current: number; next: number; total: number }; pageItem: { id: number; label: string; href: string } | null; tocItem: FoliateTocItem | null; section: { current: number; total: number }; } | null, async initReader(config: { mediaItemId: string; fileUrl: string; formatGroup: string; readingDirection: string; mangaType: string; savedPercentage?: number; savedCfi?: string; }) { this.mediaItemId = config.mediaItemId; this.settings = await loadSettings(); if (this.settings) { this.progressMode = this.settings.progress_mode || "pages"; this.readingTheme = this.settings.reading_theme || "light"; this.readingFont = this.settings.reading_font || "literata"; this.fontSize = this.settings.font_size || 18; 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")!; const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light; const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light; viewport.style.backgroundColor = themeColors.bg; this.view = document.getElementById("reader-view") as any; await loadFontBlobUrls(); 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; const link = doc.createElement("link"); link.rel = "stylesheet"; link.href = "/static/reader-fonts.css"; doc.head.append(link); doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); }); this.view.addEventListener("relocate", (e: any) => { const { fraction, location, pageItem, cfi, tocItem, section } = e.detail; this.lastRelocateDetail = { fraction, location, pageItem, tocItem, section, }; const progressStr = this.formatProgress( fraction, location, pageItem, tocItem, section, ); this.progressText = progressStr; this.sliderValue = fraction; const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider) { slider.value = fraction; slider.title = progressStr; } this.debouncedSaveProgress(fraction, location, cfi); }); const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider && this.book.dir) { slider.dir = this.book.dir; } if (this.view.getSectionFractions) { this.sectionFractionsArr = this.view.getSectionFractions(); const tickMarks = document.getElementById("tick-marks"); if (tickMarks) { for (const fraction of this.sectionFractionsArr) { const option = document.createElement("option"); option.value = fraction; tickMarks.append(option); } } } document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); if (config.savedCfi) { await this.view.init({ lastLocation: config.savedCfi }); } else if (config.savedPercentage && config.savedPercentage > 0) { await this.view.init({ lastLocation: { fraction: config.savedPercentage }, }); } else { await this.view.init({}); } this.fetchReadingSpeed(); }, debouncedSaveProgress(fraction: number, location: any, cfi: string) { if (this.saveTimeout) clearTimeout(this.saveTimeout); this.saveTimeout = setTimeout(() => { this.saveProgress(fraction, location, cfi); }, 2000); }, async saveProgress(fraction: number, location: any, cfi: string) { const token = getToken(); if (!token || !this.mediaItemId) return; try { await fetch(`/api/media-items/${this.mediaItemId}/progress`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ percentage: fraction, current_page: location?.current ?? 0, total_pages: location?.total ?? 0, epubcfi: cfi || "", }), }); } catch (_e) { // silent fail — progress save is non-critical } }, 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")!; const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light; const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light; viewport.style.backgroundColor = themeColors.bg; 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, themeName: this.readingTheme, themeMode: this.readingMode, }); }, restoreDefaults() { this.progressMode = "pages"; this.readingTheme = "light"; this.readingMode = this.detectChromeDarkMode() ? "dark" : "light"; this.readingFont = "literata"; this.fontSize = 18; this.lineHeight = 1.6; this.justify = true; this.hyphenate = true; this.applyTheme(); this.applyFont(); }, 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 */ } }, formatProgress( fraction: number, location: { current: number; next: number; total: number }, pageItem: { id: number; label: string; href: string }, tocItem: FoliateTocItem | null, section: { current: number; total: number }, ): string { const percent = new Intl.NumberFormat("en", { style: "percent" }).format( fraction, ); switch (this.progressMode) { case "percentage": return percent; case "chapter": { if (!section || !this.sectionFractionsArr.length) return percent; const idx = section.current; const startFrac = this.sectionFractionsArr[idx] ?? 0; const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; const sectionFrac = endFrac - startFrac; if (sectionFrac <= 0) return percent; const totalInSec = Math.max( 1, Math.round(sectionFrac * location.total), ); const currentInSec = Math.max( 1, Math.round((fraction - startFrac) * location.total), ); const clamped = Math.min(currentInSec, totalInSec); const label = tocItem?.label ? `${tocItem.label} · ` : ""; return `${label}${clamped} / ${totalInSec}`; } case "time-left": { if (this.readingSpeedPpm > 0 && location.total > 0) { const remaining = location.total - location.current; const mins = Math.ceil(remaining / this.readingSpeedPpm); if (mins >= 60) { const hrs = Math.floor(mins / 60); const m = mins % 60; return `${percent} · ~${hrs}h ${m}m left`; } return `${percent} · ~${mins} min left`; } return percent; } default: { const loc = pageItem ? `Page ${pageItem.label}` : `Loc ${location.current}`; return `${percent} · ${loc}`; } } }, cycleProgressMode() { const modes = ["pages", "chapter", "percentage", "time-left"]; const idx = modes.indexOf(this.progressMode); this.progressMode = modes[(idx + 1) % modes.length]; this.applyProgressMode(); if (this.lastRelocateDetail) { const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail; this.progressText = this.formatProgress( fraction, location, pageItem, tocItem, section, ); const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider) { slider.title = this.progressText; } } }, async fetchReadingSpeed() { const token = getToken(); if (!token || !this.mediaItemId) return; try { const resp = await fetch(`/readers/${this.mediaItemId}/reading-speed`, { headers: { Authorization: `Bearer ${token}` }, }); if (resp.ok) { const data = await resp.json(); this.readingSpeedPpm = data.pages_per_minute || 0; } } catch (_e) { this.readingSpeedPpm = 0; } }, applyProgressMode() { saveSettings({ progress_mode: this.progressMode as any }); }, 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();