import "foliate-js/view.js"; import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js"; import { Overlayer } from "@bookhoard/foliate-js/overlayer.js"; import { Alpine } from "../alpine"; import { loadSettings, saveSettings } from "./settings-manager"; import { getToken } from "../storage"; import { showToast } from "../toast"; import { extractPdfPages, searchPdfPages, type PdfPageText, } from "./pdf-search"; const HIGHLIGHT_COLORS = [ "#ffd54f", "#a5d6a7", "#90caf9", "#f48fb1", "#ce93d8", ]; // Rendered page-thumbnail canvases (page index → canvas). Module-level so // Alpine reactivity never wraps it and revisiting pages is instant. const thumbCache = new Map(); foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`; 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, doublePageSpread: true as boolean, chromeVisible: true, chromeBehavior: "auto-hide" as string, hideTimer: null as ReturnType | null, toolsOpen: false, helpOpen: false, pointerFine: true as boolean, fxZoomed: false as boolean, tapZonesEnabled: true as boolean, tapZoneSize: 30 as number, tapZoneTimer: null as ReturnType | null, highlightItems: [] as { id: string; text: string; note: string; color: string; cfi: string; percentage: number; pdfPage: number; pdfRects: number[][]; }[], noteItems: [] as { id: string; content: string; positionLabel: string }[], annotationsTab: "highlights" as string, newNoteText: "", highlightColors: HIGHLIGHT_COLORS, searchOpen: false, searchQuery: "", searchGroups: [] as { label: string; items: { cfi: string; pre: string; match: string; post: string }[]; }[], searchProgress: 0, searching: false, searchError: "", searchGen: 0, pdfPagesCache: null as PdfPageText[] | null, tocTab: "contents" as string, pageThumbList: [] as { index: number }[], thumbObserver: null as IntersectionObserver | null, pdfSearchKeys: [] as string[], backStack: [] as { cfi?: string; page?: number }[], selectionPopover: { open: false, mode: "create" as "create" | "edit", x: 0, y: 0, text: "", cfi: "", id: "", color: "#ffd54f", note: "", noteOpen: false, // PDF/fixed-layout anchor: page index + page-fraction rects // (empty for CFI-anchored reflowable highlights). pdfPage: -1, pdfRects: [] as number[][], }, pdfSelDoc: null as any, progressText: "", progressLabel: "", progressMain: "", sliderValue: 0, settings: null as ReaderSettings | null, justify: true, hyphenate: true, tocOpen: false, settingsOpen: false, bookmarksOpen: false, bookmarkItems: [] as { id: string; title: string; positionLabel: string; cfi: string; page: number | null; }[], tocItems: [] as any[], mediaItemId: "" as string, saveTimeout: null as ReturnType | null, initTime: 0 as number, contextText: "" as string, 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, chapterBoundaries: [] as { id: number; label: string; startPage: number; }[], get themeSwatches(): { value: string; label: string; bg: string; fg: string; }[] { const dark = this.readingMode === "dark"; return Object.entries(THEME_COLORS).map(([value, set]) => { const colors = dark ? set.dark : set.light; return { value, label: value.charAt(0).toUpperCase() + value.slice(1), bg: colors.bg, fg: colors.fg, }; }); }, get searchMatchCount(): number { return this.searchGroups.reduce( (n: number, g: { items: unknown[] }) => n + g.items.length, 0, ); }, init() { this.pointerFine = window .matchMedia("(hover: hover) and (pointer: fine)") .matches; const link = document.getElementById("reader-back"); if (!link) return; const storageKey = "reader_back"; if (document.referrer) { try { const ref = new URL(document.referrer); if ( ref.origin === window.location.origin && !ref.pathname.startsWith("/readers/") && ref.pathname !== window.location.pathname ) { sessionStorage.setItem(storageKey, ref.pathname + ref.search); } } catch {} } else { sessionStorage.removeItem(storageKey); } const backUrl = sessionStorage.getItem(storageKey) || link.getAttribute("href") || "/dashboard"; link.addEventListener("click", (e: Event) => { e.preventDefault(); sessionStorage.removeItem(storageKey); window.location.href = backUrl; }); }, async initReader(config: { mediaItemId: string; fileUrl: string; formatGroup: string; readingDirection: string; mangaType: string; savedPercentage?: number; savedCfi?: string; savedPage?: number; savedTotalPages?: number; bookmarks?: { id: string; title: string; positionLabel: string; cfi: string; page: number | null; }[]; }) { this.mediaItemId = config.mediaItemId; this.bookmarkItems = config.bookmarks ?? []; 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; this.doublePageSpread = this.settings.double_page_spread ?? true; const behavior = this.settings.chrome_behavior || "auto-hide"; this.chromeBehavior = behavior === "always-visible" ? "always-visible" : "auto-hide"; this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true; this.tapZoneSize = this.settings.tap_zone_size || 30; const mode = this.settings.pdf_interaction_mode; if (mode === "pan" || mode === "text" || mode === "select") { this.interactionMode = mode; } 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, { pdf: {}, }); this.renderer = this.view.renderer; this.book = this.view.book; // Populate the TOC eagerly (PDF outlines and EPUB TOCs alike) so the // drawer never depends on lazy-toggle timing; toggleTOC only opens. if (this.book?.toc?.length) { this.tocItems = this.flattenTOC(this.book.toc); } this.isFixedLayout = this.view.isFixedLayout; if (this.isFixedLayout) { // Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl", // but makeComicBook never sets dir. Apply it from metadata so RTL page // turns (and spread ordering) read right-to-left. Reflowable EPUBs keep // whatever direction foliate read from the OPF. if (config.readingDirection === "rtl" && this.book.dir !== "rtl") { this.book.dir = "rtl"; } this.isPDF = (this.renderer as any).isPDF; // Click on a PDF/fixed-layout highlight rect → edit popover. this.renderer.addEventListener( "show-rect-annotation", (e: any) => { const { key, clientX, clientY } = e.detail; const h = this.highlightItems.find((x) => x.id === key); if (!h) return; this.openSelectionPopover({ mode: "edit", x: clientX, y: clientY, text: h.text, cfi: "", id: h.id, color: h.color, note: h.note, pdfPage: h.pdfPage, pdfRects: h.pdfRects, }); }, ); this.renderer.addEventListener("zoom", () => { this.zoomPercent = this.renderer.zoomPercent; this.fxZoomed = this.renderer.zoom != null; }); this.computeFixedLayoutChapterBoundaries(); this.applyDoublePageSpread(); } else { this.renderer.setStyles?.(this.buildCSS()); } this.view.addEventListener("load", (e: any) => { const { doc, index } = 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), ); // Pointer activity inside page iframes should keep the chrome awake // (fixed-layout pages and EPUB sections are separate documents). doc.addEventListener("pointermove", () => this.pokeChrome(), { passive: true, }); // Tap zones inside the page document (iframe events never bubble // out to the host document, so the viewport listeners miss them). if (window.matchMedia("(pointer: coarse)").matches) { this.attachTapZoneListeners(doc as unknown as HTMLElement, true); } // Text selection → highlight popover (reflowable EPUB only; // fixed-layout highlight overlays are a later milestone). if (!this.isFixedLayout) { const checkSelection = () => { const sel = doc.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) { if (this.selectionPopover.mode === "create") this.hideSelectionPopover(); return; } const range = sel.getRangeAt(0); const text = sel.toString().replace(/\s+/g, " ").trim(); if (!text) return; let cfi: string; try { cfi = this.view.getCFI(index, range); } catch { return; } const frame = doc.defaultView?.frameElement as HTMLElement | null; const iframeRect = frame?.getBoundingClientRect(); const rect = range.getBoundingClientRect(); this.openSelectionPopover({ mode: "create", x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2, y: (iframeRect?.top ?? 0) + rect.top, text, cfi, }); }; doc.addEventListener( "pointerup", () => setTimeout(checkSelection, 0), { passive: true }, ); doc.addEventListener( "keyup", (ev: KeyboardEvent) => { if (ev.shiftKey) setTimeout(checkSelection, 0); }, { passive: true }, ); } // PDF textLayer selection → rect-fraction highlight popover. // Detected structurally: renderer.isPDF isn't set until the first // spread renders (during view.init), which is after this listener // attaches — the stale copy here would always be falsy. if (this.isFixedLayout && doc.querySelector(".textLayer")) { const checkPDFSelection = () => { const sel = doc.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return; const range = sel.getRangeAt(0); const text = sel.toString().replace(/\s+/g, " ").trim(); if (!text) return; // Denominator: the element whose post-transform screen rect IS // the visible page. For PDFs that's the rendered canvas — pdf.js // scales by 1/devicePixelRatio, so documentElement's rect // is dpr× too small and would inflate every fraction (highlight // shifted right/oversized on any dpr != 1 display). The canvas's // rect is in the same transform-inclusive space as the textLayer // span rects, so the dpr scaling cancels exactly. const denom = (doc.querySelector("#canvas canvas") as HTMLElement) || (doc.querySelector("img") as HTMLElement); let dr = denom?.getBoundingClientRect(); if (!dr || !dr.width || !dr.height) { const vw = doc.defaultView; dr = { left: 0, top: 0, width: vw?.innerWidth || 1, height: vw?.innerHeight || 1, } as DOMRect; } const rects: number[][] = []; for (const r of range.getClientRects()) { const x = (r.left - dr.left) / dr.width; const y = (r.top - dr.top) / dr.height; const w = r.width / dr.width; const h = r.height / dr.height; if (w > 0 && h > 0) rects.push([x, y, w, h]); } if (!rects.length) return; // Map the first rect to host-space for popover placement. // The canvas's screen rect maps 1:1 onto the host iframe box // (the visible page fills the iframe), so sx/sy are 1 for PDFs; // kept general for the comic img fallback. const frame = doc.defaultView?.frameElement as HTMLElement | null; if (!frame) return; const fr = frame.getBoundingClientRect(); const first = range.getBoundingClientRect(); const sx = fr.width / dr.width; const sy = fr.height / dr.height; this.pdfSelDoc = doc; this.openSelectionPopover({ mode: "create", x: fr.left + first.left * sx + (first.width * sx) / 2, y: fr.top + first.top * sy, text, cfi: "", pdfPage: index, pdfRects: rects, }); }; doc.addEventListener( "pointerup", () => setTimeout(checkPDFSelection, 0), { passive: true }, ); } if (!this.isFixedLayout) { this.computeChapterPageBoundaries(doc); doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc)); } }); // ----- highlight rendering (foliate overlayer pipeline) ----- // Internal link clicks (footnotes, cross-references): record where we // came from so the back-to-location stack can return; foliate then // navigates on its own. this.view.addEventListener("link", () => this.pushBackStack()); this.view.addEventListener("draw-annotation", (e: any) => { const { draw, annotation } = e.detail; draw(Overlayer.highlight, { color: annotation.color || "#ffd54f" }); }); this.view.addEventListener("show-annotation", (e: any) => { const { value, index, range } = e.detail; const h = this.highlightItems.find((x) => x.cfi === value); if (!h) return; const doc = this.renderer ?.getContents?.() ?.find((c: any) => c.index === index)?.doc; const frame = doc?.defaultView?.frameElement as HTMLElement | null; const iframeRect = frame?.getBoundingClientRect(); const rect = range.getBoundingClientRect(); this.openSelectionPopover({ mode: "edit", x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2, y: (iframeRect?.top ?? 0) + rect.top, text: h.text, cfi: h.cfi, id: h.id, color: h.color, note: h.note, }); }); this.view.addEventListener("create-overlay", () => { this.renderAllHighlights(); }); this.view.addEventListener("relocate", (e: any) => { const { fraction, location, pageItem, cfi, tocItem, section } = e.detail; this.hideSelectionPopover(); this.lastRelocateDetail = { fraction, location, pageItem, tocItem, section, }; const progressParts = this.formatProgressParts( fraction, location, pageItem, tocItem, section, ); this.setProgress(progressParts); this.sliderValue = fraction; for (const slider of this.progressSliders()) { slider.value = fraction; } const range = e.detail.range as Range | undefined; if (range) { const text = range.toString().replace(/\s+/g, " ").trim(); this.contextText = text ? text.slice(0, 100) : ""; } this.debouncedSaveProgress(fraction, location, cfi); }); if (this.book.dir) { for (const slider of this.progressSliders()) { 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 (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) { // Fixed-layout & comics: a page index is the exact, universal locator. // A bare number navigates directly to the section index in foliate. await this.view.init({ lastLocation: config.savedPage - 1 }) } else 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({}) } // The renderer only knows it's a PDF once frames exist (they carry // pdf.js onZoom), i.e. after init has rendered the first spread. // Read it now and apply the saved pointer mode — this also makes the // Smart|Pan|Text control appear for PDFs. this.isPDF = !!this.renderer?.isPDF; if (this.isPDF) { this.renderer.setAttribute("interaction-mode", this.interactionMode); } this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; this.initTime = Date.now(); this.fetchReadingSpeed(); this.refreshAnnotations(); this.setupChrome(); this.setupTapZones(); }, // Chrome visibility: bars overlay the edge-to-edge reading surface. // 'auto-hide' keeps them up while the pointer is active and fades them // out after a quiet period; 'always-visible' pins them. setupChrome() { document.addEventListener("pointermove", () => this.pokeChrome(), { passive: true, }); // Dismiss the selection popover and tools menu on clicks outside them // (iframe clicks are covered by the selection tracker's collapsed // check; the ⋯ toggle is exempt so its own click re-toggles cleanly). document.addEventListener("pointerdown", (e: PointerEvent) => { const target = e.target as HTMLElement; if (this.selectionPopover.open && !target?.closest?.("#selection-popover")) { this.hideSelectionPopover(); } if (this.toolsOpen && !target?.closest?.(".reader-tools-popover, [data-tools-toggle]")) { this.toolsOpen = false; } }, { passive: true }); if (this.chromeBehavior === "always-visible") { this.chromeVisible = true; return; } this.pokeChrome(); }, pokeChrome() { this.chromeVisible = true; if (this.chromeBehavior !== "auto-hide") return; if (this.hideTimer) clearTimeout(this.hideTimer); this.hideTimer = setTimeout(() => { if (this.anyDrawerOpen() || this.chromeHasFocus()) return; this.chromeVisible = false; }, 2500); }, chromeHasFocus(): boolean { const el = document.activeElement; if (!el) return false; const tag = el.tagName; return ( (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") && !!el.closest("#reader-topbar, #reader-bottombar") ); }, applyChromeBehavior() { if (this.chromeBehavior !== "auto-hide") this.chromeVisible = true; else this.pokeChrome(); saveSettings({ chrome_behavior: this.chromeBehavior }); }, // ----- tap zones (touch devices) ----- // Kindle-style: tap left/right margins to page, center to toggle chrome. // Pointer-based and passive, so drags (pan/selection/swipe) never // trigger. Attached to the viewport and inside every page document // (iframe contents don't bubble to the host document). setupTapZones() { if (!window.matchMedia("(pointer: coarse)").matches) return; const vp = document.getElementById("reader-viewport"); if (vp) this.attachTapZoneListeners(vp as HTMLElement, false); }, attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) { let downX = 0; let downY = 0; let downT = 0; let downId = -1; let moved = false; surface.addEventListener( "pointerdown", (e: PointerEvent) => { if (!e.isPrimary || e.pointerType === "mouse") return; downX = e.clientX; downY = e.clientY; downT = Date.now(); downId = e.pointerId; moved = false; }, { passive: true }, ); surface.addEventListener( "pointermove", (e: PointerEvent) => { if (e.pointerId !== downId) return; if (Math.hypot(e.clientX - downX, e.clientY - downY) > 12) moved = true; }, { passive: true }, ); surface.addEventListener( "pointerup", (e: PointerEvent) => { if (e.pointerId !== downId) return; downId = -1; if (moved || Date.now() - downT > 500) return; if (!this.tapZonesEnabled) return; const target = e.target as HTMLElement | null; if ( target?.closest?.( "a, button, input, textarea, select, [contenteditable]", ) ) return; const sel = isDoc ? (surface as any).getSelection?.() : null; if (sel?.toString?.()) return; // No tap actions while a fixed-layout page is zoomed — taps then // belong to the content (and double-tap zoom). if (this.isFixedLayout && this.renderer?.zoom != null) return; const width = (isDoc ? (surface as any).documentElement.clientWidth : (surface as HTMLElement).getBoundingClientRect().width) || 1; const relX = (e.clientX - (isDoc ? 0 : (surface as HTMLElement).getBoundingClientRect().left)) / width; this.routeTapDebounced(relX); }, { passive: true }, ); }, // A short debounce lets a second tap (double-tap zoom in the fixed-layout // engine) cancel the zone action instead of also paging. routeTapDebounced(relX: number) { if (this.tapZoneTimer) { clearTimeout(this.tapZoneTimer); this.tapZoneTimer = null; return; } this.tapZoneTimer = setTimeout(() => { this.tapZoneTimer = null; const zone = this.tapZoneSize / 100; if (relX < zone) this.goLeft(); else if (relX > 1 - zone) this.goRight(); else this.toggleChromeManually(); }, 280); }, toggleChromeManually() { if (this.chromeVisible) { this.chromeVisible = false; if (this.hideTimer) clearTimeout(this.hideTimer); } else { this.pokeChrome(); } }, applyTapZoneSettings() { saveSettings({ tap_zones_enabled: this.tapZonesEnabled, tap_zone_size: this.tapZoneSize, }); }, // ----- annotations (highlights + notes) ----- openSelectionPopover(opts: { mode: "create" | "edit"; x: number; y: number; text: string; cfi: string; id?: string; color?: string; note?: string; pdfPage?: number; pdfRects?: number[][]; }) { const p = this.selectionPopover; p.mode = opts.mode; p.text = opts.text; p.cfi = opts.cfi; p.id = opts.id ?? ""; p.color = opts.color || "#ffd54f"; p.note = opts.note ?? ""; p.noteOpen = !!p.note && opts.mode === "edit"; p.pdfPage = opts.pdfPage ?? -1; p.pdfRects = opts.pdfRects ?? []; // Clamp so the popover stays on screen (it anchors bottom-center). const w = window.innerWidth; const h = window.innerHeight; p.x = Math.min(Math.max(opts.x, 90), w - 90); p.y = Math.min(Math.max(opts.y, 60), h - 60); p.open = true; }, hideSelectionPopover() { this.selectionPopover.open = false; this.selectionPopover.noteOpen = false; }, renderAllHighlights() { for (const hl of this.highlightItems) { if (hl.pdfPage >= 0) { this.renderer?.addRectAnnotation?.({ key: hl.id, index: hl.pdfPage, rects: hl.pdfRects, color: hl.color, }); } else { this.view ?.addAnnotation({ value: hl.cfi, color: hl.color, note: hl.note, id: hl.id, }) ?.catch?.(() => {}); } } }, mapHighlightRow(r: any) { let cfi = r.epubcfi_start ?? ""; let pdfPage = -1; let pdfRects: number[][] = []; // Fixed-layout anchors are stored as a JSON descriptor in the CFI // column (page index + page-fraction rects). if (cfi.startsWith("{")) { try { const a = JSON.parse(cfi); if ( a && typeof a.page === "number" && Array.isArray(a.rects) && a.rects.length ) { pdfPage = a.page; pdfRects = a.rects; cfi = ""; } } catch { /* not ours; leave as-is */ } } return { id: r.id, text: r.selection_text ?? "", note: r.note_text ?? "", color: r.color ?? "#ffff00", cfi, percentage: r.percentage_start ?? 0, pdfPage, pdfRects, }; }, async refreshAnnotations() { const token = getToken(); if (!token || !this.mediaItemId) return; try { const [hlResp, noteResp] = await Promise.all([ fetch(`/api/media-items/${this.mediaItemId}/highlights`, { headers: { Authorization: `Bearer ${token}` }, }), fetch(`/api/media-items/${this.mediaItemId}/notes`, { headers: { Authorization: `Bearer ${token}` }, }), ]); if (hlResp.ok) { const rows = await hlResp.json(); this.highlightItems = (rows as any[]) .map((r) => this.mapHighlightRow(r)) .filter((hl: any) => hl.cfi || hl.pdfPage >= 0); this.renderAllHighlights(); } if (noteResp.ok) { const rows = await noteResp.json(); this.noteItems = (rows as any[]).map((r) => ({ id: r.id, content: r.content ?? "", positionLabel: r.position ?? "", })); } } catch (_e) { /* annotations are non-critical; leave lists as-is */ } }, async createHighlight(color: string) { const p = this.selectionPopover; const token = getToken(); if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return; const pdfAnchor = p.pdfPage >= 0 ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) : ""; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/highlights`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ selection_text: p.text, start_position: "", end_position: "", epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi, color, note_text: "", percentage_start: this.lastRelocateDetail?.fraction ?? 0, }), }, ); if (!resp.ok) return; const row = await resp.json(); const hl = this.mapHighlightRow(row); this.highlightItems.push(hl); if (hl.pdfPage >= 0) { this.renderer?.addRectAnnotation?.({ key: hl.id, index: hl.pdfPage, rects: hl.pdfRects, color, }); // Clear the textLayer selection so the highlight reads cleanly. try { this.pdfSelDoc?.getSelection()?.removeAllRanges(); } catch { /* doc may be gone */ } } else { this.view?.addAnnotation({ value: p.cfi, color, note: "", id: row.id, }); } this.hideSelectionPopover(); } catch (_e) { /* ignore highlight errors */ } }, async saveHighlightChanges() { const p = this.selectionPopover; const token = getToken(); if (!token || !this.mediaItemId || !p.id) return; const anchor = p.pdfPage >= 0 ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) : p.cfi; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/highlights/${p.id}`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ selection_text: p.text, start_position: "", end_position: "", epubcfi_start: anchor, color: p.color, note_text: p.note, }), }, ); if (!resp.ok) return; const row = await resp.json(); const idx = this.highlightItems.findIndex((h) => h.id === p.id); if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row); // Re-add so the overlay redraws with the new color. if (p.pdfPage >= 0) { this.renderer?.addRectAnnotation?.({ key: p.id, index: p.pdfPage, rects: p.pdfRects, color: p.color, }); } else { this.view?.addAnnotation({ value: p.cfi, color: p.color, note: p.note, id: p.id, }); } p.noteOpen = false; } catch (_e) { /* ignore highlight errors */ } }, async deleteHighlightById(id: string) { const token = getToken(); if (!token || !this.mediaItemId) return; const hl = this.highlightItems.find((h) => h.id === id); try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/highlights/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, ); if (!resp.ok && resp.status !== 204) return; this.highlightItems = this.highlightItems.filter((h) => h.id !== id); if (hl?.pdfPage >= 0) { this.renderer?.removeRectAnnotation?.(id); } else if (hl?.cfi) { this.view?.deleteAnnotation({ value: hl.cfi }); } this.hideSelectionPopover(); } catch (_e) { /* ignore highlight errors */ } }, async copySelectionText() { try { await navigator.clipboard.writeText(this.selectionPopover.text); this.hideSelectionPopover(); } catch (_e) { /* clipboard unavailable */ } }, goToHighlight(hl: { cfi: string; pdfPage: number; }) { if (hl.pdfPage >= 0) { // Fixed-layout: a bare number navigates to the section (page) index. this.pushBackStack(); this.view?.goTo?.(hl.pdfPage); this.closeDrawers(); } else if (hl.cfi) { this.pushBackStack(); this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {}); this.closeDrawers(); } }, async addNote(content: string) { const token = getToken(); if (!token || !this.mediaItemId || !content.trim()) return; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/notes`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ content, position: !this.isFixedLayout ? `cfi:${this.view?.lastLocation?.cfi ?? ""}` : `page:${(this.renderer?.index ?? 0) + 1}`, }), }, ); if (!resp.ok) return; await this.refreshAnnotations(); } catch (_e) { /* ignore note errors */ } }, async deleteNoteById(id: string) { const token = getToken(); if (!token || !this.mediaItemId) return; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/notes/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, ); if (resp.ok || resp.status === 204) { this.noteItems = this.noteItems.filter((n) => n.id !== id); } } catch (_e) { /* ignore note errors */ } }, debouncedSaveProgress(fraction: number, location: any, cfi: string) { if (Date.now() - this.initTime < 5000) return; 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; const body: Record = { percentage: fraction, current_page: this.isFixedLayout ? (this.renderer?.index ?? 0) + 1 : location?.current ?? 0, total_pages: this.isFixedLayout ? this.book?.sections?.filter((s: any) => s.linear !== "no") ?.length ?? 0 : location?.total ?? 0, reading_mode: this.readingMode || undefined, }; if (!this.isFixedLayout) { body.epubcfi = cfi || ""; body.context_text = this.contextText || ""; } const tocItem = this.lastRelocateDetail?.tocItem; if (tocItem) { if (tocItem.label) { const boundaryIdx = this.chapterBoundaries.findIndex( (b: any) => b.label === tocItem.label, ); if (boundaryIdx !== -1) { body.chapter = boundaryIdx; } } } if (this.isFixedLayout && this.renderer?.zoomPercent) { body.zoom_level = this.renderer.zoomPercent / 100; } try { await fetch(`/api/media-items/${this.mediaItemId}/progress`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); } 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; this.fxZoomed = false; }, toggleMagnifier() { if (!this.isFixedLayout) return; this.renderer.toggleMagnifier(); this.magnifierEnabled = this.renderer.zoomMagnifierEnabled; }, setInteractionMode(mode: string) { if (!this.isFixedLayout || !this.isPDF) return; if (mode !== "select" && mode !== "pan" && mode !== "text") return; this.renderer.setAttribute("interaction-mode", mode); this.interactionMode = mode; saveSettings({ pdf_interaction_mode: mode }); }, // Reset pan offsets while keeping the current zoom level. recenterView() { if (!this.isFixedLayout || !this.renderer) return; this.renderer.recenter?.(); this.renderer.dragOffset = { x: 0, y: 0 }; }, applyFitMode(mode: string) { if (!this.isFixedLayout || !this.renderer) return; if (mode === "fit-width" || mode === "fit-page") { this.renderer.setAttribute("zoom", mode); this.renderer.dragOffset = { x: 0, y: 0 }; this.fxZoomed = false; } }, toggleSpread() { this.doublePageSpread = !this.doublePageSpread; this.applyDoublePageSpread(); }, applyDoublePageSpread() { if (!this.isFixedLayout || !this.renderer) return; this.renderer.setAttribute( "spread", this.doublePageSpread ? "auto" : "none", ); saveSettings({ double_page_spread: this.doublePageSpread }); }, 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() { const opening = !this.tocOpen; this.closeDrawers(); this.tocOpen = opening; if (opening && this.tocItems.length === 0 && this.book?.toc) { this.tocItems = this.flattenTOC(this.book.toc); } }, toggleSettings() { const opening = !this.settingsOpen; this.closeDrawers(); this.settingsOpen = opening; }, toggleBookmarks() { const opening = !this.bookmarksOpen; this.closeDrawers(); this.bookmarksOpen = opening; }, toggleSearch() { // Reflowable books use foliate's DOM search; PDFs use extracted text. // Comics have no text at all. if (this.isFixedLayout && !this.isPDF) return; const opening = !this.searchOpen; this.closeDrawers(); this.searchOpen = opening; if (opening) { (this as any).$nextTick(() => (this as any).$refs.searchInput?.focus(), ); } }, toggleTools() { this.toolsOpen = !this.toolsOpen; }, toggleHelp() { this.helpOpen = !this.helpOpen; }, // Desktop edge page-turn zones: fine-pointer devices only, and disabled // while a fixed-layout page is zoomed (edge clicks then belong to the // content: panning, text selection, highlight editing). get edgeZonesActive(): boolean { if (!this.pointerFine) return false; return !(this.isFixedLayout && this.fxZoomed); }, anyDrawerOpen(): boolean { return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen; }, closeDrawers() { this.tocOpen = false; this.settingsOpen = false; this.bookmarksOpen = false; this.searchOpen = false; this.toolsOpen = false; }, // ----- in-book search (reflowable) ----- // view.search() is an async generator: it progressively scans sections, // yields {progress} per section and {label, subitems} per section with // hits, and draws outline highlights through the overlayer pipeline. // A generation counter discards results from superseded searches. async runSearch() { const q = this.searchQuery.trim(); if (!q) { this.clearSearchResults(); return; } this.searchGen++; const gen = this.searchGen; this.searching = true; this.searchProgress = 0; this.searchGroups = []; this.searchError = ""; try { if (this.isFixedLayout && this.isPDF) { await this.runPdfSearch(q, gen); } else { await this.runEpubSearch(q, gen); } } catch (e) { // Swallowing this silently made a wiring bug look like "no matches". console.warn("search failed:", e); this.searchError = "Search failed (see browser console)"; } if (gen === this.searchGen) this.searching = false; }, async runEpubSearch(q: string, gen: number) { for await (const r of this.view.search({ query: q })) { if (gen !== this.searchGen) return; if (r === "done") break; if (r && typeof r === "object") { if (typeof r.progress === "number") { this.searchProgress = r.progress; continue; } if (Array.isArray(r.subitems) && r.subitems.length) { this.searchGroups.push({ label: r.label || `Section ${this.searchGroups.length + 1}`, items: r.subitems.map((s: any) => ({ cfi: s.cfi, page: null, pre: s.excerpt?.pre ?? "", match: s.excerpt?.match ?? "", post: s.excerpt?.post ?? "", })), }); } } } }, async runPdfSearch(q: string, gen: number) { // CRITICAL: unwrap Alpine's reactive proxy. `this.book` is a plain // object, so reactivity wraps it — and reading `.pdf` through the // proxy wraps the PDFDocumentProxy too. pdf.js uses #private fields, // so calling getPage() on the proxy throws "cannot read private // member", which used to be swallowed as an empty result set. const rawBook = (Alpine as any).raw ? (Alpine as any).raw(this.book) : this.book; const pdf = rawBook?.pdf; if (!pdf) { this.searchError = "PDF text engine unavailable"; return; } if (!this.pdfPagesCache) { // Extraction reports progress; it's cached so re-searches are instant. this.pdfPagesCache = await extractPdfPages(pdf, (fraction) => { if (gen === this.searchGen) this.searchProgress = fraction; }); } if (gen !== this.searchGen) return; const hits = searchPdfPages(this.pdfPagesCache, q); const byPage = new Map(); for (const hit of hits) { const list = byPage.get(hit.page) ?? []; list.push(hit); byPage.set(hit.page, list); } const groups: typeof this.searchGroups = []; let i = 0; for (const [page, list] of byPage) { groups.push({ label: `Page ${page + 1}`, items: list.map((hit) => { const key = `pdfsearch:${i++}`; this.pdfSearchKeys.push(key); this.renderer?.addRectAnnotation?.({ key, index: page, rects: hit.rects, color: "#ffd54f", }); return { cfi: "", page, pre: hit.pre, match: hit.match, post: hit.post, }; }), }); } this.searchGroups = groups; }, clearSearchResults() { this.searchGen++; this.searching = false; this.searchError = ""; this.searchGroups = []; this.searchProgress = 0; for (const key of this.pdfSearchKeys) { this.renderer?.removeRectAnnotation?.(key); } this.pdfSearchKeys = []; this.view?.clearSearch?.(); }, goToSearchResult(item: { cfi?: string; page?: number | null }) { if (item.cfi) { this.pushBackStack(); this.view?.goTo?.(item.cfi); } else if (item.page != null) { this.pushBackStack(); this.view?.goTo?.(item.page); } else return; this.closeDrawers(); }, // ----- back-to-location stack ----- // Research pattern: jump somewhere (search hit, footnote link, TOC // entry), read, then return. Recorded before every programmatic jump // and on every internal link click (footnotes). Ordinary paging never // touches the stack. pushBackStack() { let loc: { cfi?: string; page?: number } | null = null; if (this.isFixedLayout) { const page = this.renderer?.index; if (typeof page === "number" && page >= 0) loc = { page }; } else { const cfi = this.view?.lastLocation?.cfi; if (cfi) loc = { cfi }; } if (!loc) return; const top = this.backStack[this.backStack.length - 1]; if (top && top.cfi === loc.cfi && top.page === loc.page) return; this.backStack.push(loc); if (this.backStack.length > 50) this.backStack.shift(); }, goBackToLocation() { const loc = this.backStack.pop(); if (!loc) return; if (loc.cfi) this.view?.goTo?.(loc.cfi); else if (typeof loc.page === "number") this.view?.goTo?.(loc.page); }, 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.pushBackStack(); this.view.goTo(item.href); this.tocOpen = false; } }, // ----- page thumbnails (fixed-layout: PDF via pdf.js, comics via the // page image blobs). Lazy-rendered with an IntersectionObserver so only // visible cells pay extraction/render cost. ----- get currentPageIndex(): number { return typeof this.renderer?.index === "number" ? this.renderer.index : -1; }, initPageThumbs() { if (!this.isFixedLayout || !this.book) return; if (!this.pageThumbList.length) { const sections = (this.book as any).sections ?? []; this.pageThumbList = sections.map((_s: any, i: number) => ({ index: i })); } (this as any).$nextTick(() => { const grid = document.getElementById("page-thumb-grid"); if (!grid || this.thumbObserver) { this.resumeThumbObserver(); return; } const root = grid.closest(".reader-drawer-body"); const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; io.unobserve(entry.target); const page = parseInt( (entry.target as HTMLElement).dataset.page ?? "", 10, ); if (!isNaN(page)) this.renderPageThumb(page, entry.target as HTMLElement); } }, { root: root instanceof Element ? root : null, rootMargin: "200px" }, ); this.thumbObserver = io; for (const el of Array.from( grid.querySelectorAll(".reader-thumb-img"), )) { io.observe(el); } this.scrollToCurrentThumb(); }); }, resumeThumbObserver() { const grid = document.getElementById("page-thumb-grid"); if (grid && this.thumbObserver) { for (const el of Array.from( grid.querySelectorAll( ".reader-thumb-img:not([data-done])", ), )) { this.thumbObserver.observe(el); } } this.scrollToCurrentThumb(); }, scrollToCurrentThumb() { const cur = document.querySelector( "#page-thumb-grid .reader-thumb.active", ); cur?.scrollIntoView({ block: "center" }); }, async renderPageThumb(page: number, container: HTMLElement) { if (container.dataset.done) return; container.dataset.done = "1"; try { const cached = thumbCache.get(page); if (cached) { container.appendChild(cached); return; } const rawBook = (Alpine as any).raw ? (Alpine as any).raw(this.book) : this.book; if (this.isPDF && rawBook?.pdf) { const pdfPage = await rawBook.pdf.getPage(page + 1); const base = pdfPage.getViewport({ scale: 1 }); const scale = 110 / base.width; const viewport = pdfPage.getViewport({ scale }); const canvas = document.createElement("canvas"); canvas.width = Math.ceil(viewport.width); canvas.height = Math.ceil(viewport.height); await pdfPage.render({ canvasContext: canvas.getContext("2d")!, viewport, }).promise; thumbCache.set(page, canvas); container.appendChild(canvas); } else { // Comic: section.load() yields a page-document blob URL; extract // the embedded image URL, draw it small, then free the full-size // blob (unload) so thumbnailing doesn't hoard page images. const section = rawBook?.sections?.[page]; const url = await section?.load?.(); if (!url) return; const html = await (await fetch(url)).text(); const m = html.match(/src="(blob:[^"]+)"/); if (!m) return; const img = new Image(); await new Promise((res, rej) => { img.onload = () => res(); img.onerror = () => rej(new Error("img load")); img.src = m[1]; }); const scale = 110 / (img.naturalWidth || 1); const canvas = document.createElement("canvas"); canvas.width = 110; canvas.height = Math.round((img.naturalHeight || 150) * scale); canvas.getContext("2d")!.drawImage(img, 0, 0, canvas.width, canvas.height); thumbCache.set(page, canvas); container.appendChild(canvas); // Free the full-size page blob unless it's the page on screen // (revoking a URL an iframe already loaded is harmless, but the // current page may be re-requested on re-render). if (this.renderer?.index !== page) section.unload?.(); } } catch (e) { delete container.dataset.done; console.warn("thumbnail render failed for page", page + 1, e); } }, goToPage(index: number) { if (!this.view || typeof index !== "number" || index < 0) return; this.pushBackStack(); this.view.goTo(index); this.tocOpen = false; }, goToBookmark(item: { cfi: string; page: number | null }) { if (!this.view) return; if (item.cfi) { this.pushBackStack(); this.view.goTo(item.cfi); } else if (item.page != null && item.page > 0) { this.pushBackStack(); // Fixed-layout/comic: sections are pages; foliate takes an index. this.view.goTo(item.page - 1); } else { return; } 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.chromeBehavior = "auto-hide"; this.applyChromeBehavior(); this.interactionMode = "select"; this.setInteractionMode("select"); this.applyTheme(); this.applyFont(); }, async refreshBookmarks() { const token = getToken(); if (!token || !this.mediaItemId) return; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/bookmarks`, { headers: { Authorization: `Bearer ${token}` } }, ); if (!resp.ok) return; const rows = await resp.json(); this.bookmarkItems = (rows as any[]).map((r) => ({ id: r.id, title: r.title ?? "", positionLabel: r.position?.String ?? r.position ?? "", cfi: r.cfi_position?.String ?? r.cfi_position ?? "", page: r.page_number != null ? (r.page_number?.Int32 ?? r.page_number) : null, })); } catch (_e) { /* leave the existing list on fetch failure */ } }, async addBookmark() { const token = getToken(); if (!token || !this.view || !this.mediaItemId) return; const location = this.view.lastLocation; if (!location) return; const cfi = (!this.isFixedLayout && location.cfi) || ""; const page = this.isFixedLayout ? (this.renderer?.index ?? 0) + 1 : 0; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/bookmarks`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ title: `Bookmark at ${this.progressText || "current position"}`, position: this.isFixedLayout ? `page:${page}` : cfi ? `cfi:${cfi}` : "", cfi_position: cfi, page_number: page, chapter_number: this.chapterNumberForProgress() || 0, percentage: location.fraction ?? 0, }), }, ); if (resp.ok) { await this.refreshBookmarks(); showToast( `Bookmark added — ${this.progressText || "current position"}`, "success", 2500, ); } } catch (_e) { /* network failures surface via the toast fetch interceptor */ } }, async deleteBookmark(id: string) { const token = getToken(); if (!token || !this.mediaItemId) return; try { const resp = await fetch( `/api/media-items/${this.mediaItemId}/bookmarks/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, ); if (resp.ok || resp.status === 204) { this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id); } } catch (_e) { /* ignore bookmark errors for now */ } }, chapterNumberForProgress(): number { const tocItem = this.lastRelocateDetail?.tocItem; if (!tocItem?.label) return 0; const idx = this.chapterBoundaries.findIndex( (b: any) => b.label === tocItem.label, ); return idx === -1 ? 0 : idx; }, formatProgressParts( fraction: number, location: { current: number; next: number; total: number }, pageItem: { id: number; label: string; href: string }, tocItem: FoliateTocItem | null, section: { current: number; total: number }, ): { label: string; main: string } { const percent = new Intl.NumberFormat("en", { style: "percent" }).format( fraction, ); const chapterLabel = tocItem?.label ? `${tocItem.label} · ` : ""; switch (this.progressMode) { case "percentage": return { label: "", main: percent }; case "chapter": { if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) { const totalSections = this.book?.sections?.filter( (s: any) => s.linear !== "no", )?.length ?? 0; const currentPage = this.renderer?.index ?? 0; const boundaryIdx = this.chapterBoundaries.findIndex( (b) => b.id === tocItem.id, ); if (boundaryIdx !== -1 && totalSections > 0) { const chapterStart = this.chapterBoundaries[boundaryIdx].startPage; const nextBoundary = this.chapterBoundaries[boundaryIdx + 1]; const chapterEnd = nextBoundary ? nextBoundary.startPage : totalSections; const chapterPages = Math.max(1, chapterEnd - chapterStart); const currentInChapter = Math.max( 1, Math.min(currentPage - chapterStart + 1, chapterPages), ); return { label: chapterLabel, main: `${currentInChapter} / ${chapterPages}`, }; } } if (tocItem && this.chapterBoundaries.length > 0) { const boundaryIdx = this.chapterBoundaries.findIndex( (b) => b.id === tocItem.id, ); if (boundaryIdx !== -1) { const currentPage = this.renderer?.page ?? 1; const chapterStart = this.chapterBoundaries[boundaryIdx].startPage; const nextBoundary = this.chapterBoundaries[boundaryIdx + 1]; const totalPages = this.renderer?.pages ?? 0; const chapterEnd = nextBoundary ? nextBoundary.startPage : totalPages > 2 ? totalPages - 1 : chapterStart + 1; const chapterPages = Math.max(1, chapterEnd - chapterStart); const currentInChapter = Math.max( 1, Math.min(currentPage - chapterStart + 1, chapterPages), ); return { label: chapterLabel, main: `${currentInChapter} / ${chapterPages}`, }; } } if (section && this.sectionFractionsArr.length > 1) { const pageInfo = this.getRenderedPageInfo(); const pageBase = pageInfo?.total ?? location.total; const idx = section.current; const startFrac = this.sectionFractionsArr[idx] ?? 0; const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; const sectionFrac = endFrac - startFrac; if (sectionFrac > 0 && pageBase > 0) { const totalInSec = Math.max( 1, Math.round(sectionFrac * pageBase), ); const currentInSec = Math.max( 1, Math.round((fraction - startFrac) * pageBase), ); const clamped = Math.min(currentInSec, totalInSec); return { label: chapterLabel, main: `${clamped} / ${totalInSec}`, }; } } if (location.total > 0) { return { label: "", main: `${percent} · ${location.current} / ${location.total}`, }; } return { label: "", main: percent }; } 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 { label: "", main: `${percent} · ~${hrs}h ${m}m left` }; } return { label: "", main: `${percent} · ~${mins} min left` }; } return { label: "", main: `${percent} · ~-- min left` }; } default: { if (this.isFixedLayout) { const pageInfo = this.getRenderedPageInfo(); if (pageInfo) { return { label: "", main: `${pageInfo.current} / ${pageInfo.total}` }; } } if (pageItem) { return { label: "", main: `${percent} · Page ${pageItem.label}` }; } const pageInfoReflow = this.getRenderedPageInfo(); if (pageInfoReflow) { return { label: "", main: `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`, }; } return { label: "", main: `${percent} · ${location.current} / ${location.total}`, }; } } }, // The reflowable and fixed-layout bottom rows each have their own slider // (only one is displayed); helpers below target whichever exists. progressSliders(): HTMLInputElement[] { const ids = [ "progress-slider", "progress-slider-fx", "progress-slider-fx-compact", ]; return ids .map((id) => document.getElementById(id) as HTMLInputElement | null) .filter((el): el is HTMLInputElement => !!el); }, setProgress(parts: { label: string; main: string }, sliderTitle?: string) { this.progressLabel = parts.label; this.progressMain = parts.main; this.progressText = parts.label + parts.main; for (const slider of this.progressSliders()) { slider.title = sliderTitle ?? this.progressText; } }, 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.setProgress( this.formatProgressParts( fraction, location, pageItem, tocItem, section, ), ); } }, 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 }); if (this.lastRelocateDetail) { const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail; this.setProgress( this.formatProgressParts( fraction, location, pageItem, tocItem, section, ), ); } }, computeFixedLayoutChapterBoundaries() { if (!this.book?.toc || !this.view) return; const flat = this.flattenTOC(this.book.toc); for (const item of flat) { try { const resolved = this.view.resolveNavigation(item.href); if (resolved?.index != null) { this.chapterBoundaries.push({ id: item.id, label: item.label, startPage: resolved.index, }); } } catch { // skip unresolvable anchors } } this.chapterBoundaries.sort( (a, b) => a.startPage - b.startPage, ); }, computeChapterPageBoundaries(doc: Document) { this.chapterBoundaries = []; if (!this.book?.toc) return; const size = this.renderer?.size; if (!size) return; const flat = this.flattenTOC(this.book.toc); for (const item of flat) { const fragment = item.href?.split("#")[1]; if (!fragment) continue; try { const el = this.book.getTOCFragment?.(doc, fragment); if (!el) continue; const rect = el.getBoundingClientRect(); const page = Math.floor(rect.left / size) + 1; this.chapterBoundaries.push({ id: item.id, label: item.label, startPage: page, }); } catch { // skip unresolvable anchors } } this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage); }, getRenderedPageInfo(): { total: number; current: number } | null { if (this.isFixedLayout) { const total = this.book?.sections?.filter( (s: any) => s.linear !== "no", )?.length ?? 0; if (total === 0) return null; const index = this.renderer?.index ?? 0; return { total, current: index + 1 }; } const pages = this.renderer?.pages; if (pages != null && pages > 2) { return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 }; } return null; }, progressTooltip(): string { const labels: Record = { pages: "Pages", chapter: "Chapter", percentage: "Percentage", "time-left": "Time Left", }; const label = labels[this.progressMode] ?? "Pages"; if ( this.progressMode === "chapter" && this.chapterBoundaries.length === 0 ) { return `${label} · no chapter data · Click to switch`; } return `${label} · Click to switch`; }, handleKeydown(event: KeyboardEvent) { const k = event.key; // Never hijack keys while the user is typing in a form control. const tag = (event.target as HTMLElement)?.tagName; const typing = tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA"; this.pokeChrome(); if (k === "ArrowLeft" || k === "h") { if (event.altKey) { event.preventDefault(); this.goBackToLocation(); } else 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.helpOpen) { this.helpOpen = false; } else if (this.selectionPopover.open) { this.hideSelectionPopover(); } else if (this.toolsOpen) { this.toolsOpen = false; } else if (this.anyDrawerOpen()) { this.closeDrawers(); } else if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) { this.toggleMagnifier(); } else { // Toggle chrome without a fresh auto-hide timer. this.chromeVisible = !this.chromeVisible; if (this.hideTimer) clearTimeout(this.hideTimer); } } else if (k === "F1") { event.preventDefault(); this.toggleHelp(); } else if (!typing) { if (k === "t") this.toggleTOC(); else if (k === "s") this.toggleSettings(); else if (k === "b") this.addBookmark(); else if (k === "?") this.toggleHelp(); else if (k === "/") { event.preventDefault(); this.toggleSearch(); } } }, })); }); Alpine.start();