Files
bookhoard/web/src/reader/reader.ts
T
john-okeefe 40d70513da feat(reader): EPUB highlights & notes — selection popover, overlayer rendering, annotations drawer
Phase 3 (EPUB half) of the reader redesign:

- Select text in a reflowable book → floating glass popover at the
  selection (5 colors, note, copy). Clicking a color creates the
  highlight via POST /api/media-items/:id/highlights, anchored by the
  foliate range CFI (epubcfi_start) with percentage position.
- Highlights render through foliate's overlayer pipeline: draw-
  annotation draws Overlayer.highlight with the stored color,
  create-overlay re-adds persisted highlights as sections load,
  show-annotation opens the edit popover when a highlight is clicked
  (recolor, edit note, copy, delete).
- Backend: highlight create/update accept epubcfi_start/end,
  note_text, and percentage fields; position validation relaxed
  (CFIs exceed the old 100-char cap); PUT routes through
  AnnotationService.SaveHighlight so edits get dedup/LWW treatment
  and actually persist note_text (the plain query can't).
- Bookmarks drawer becomes the Annotations drawer with tabs:
  Highlights (color-bar list, note previews, jump/edit/delete),
  Notes (add note at current position, list, delete — backed by the
  existing notes API), and Bookmarks (unchanged behavior).
- Popover dismissed on outside click, collapsed selection, page
  navigation, or Esc (new top-priority Esc branch).
2026-08-16 12:33:55 -04:00

1764 lines
57 KiB
TypeScript

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";
const HIGHLIGHT_COLORS = [
"#ffd54f",
"#a5d6a7",
"#90caf9",
"#f48fb1",
"#ce93d8",
];
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
const FONT_MAP: Record<string, string> = {
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<string, string> = {};
let fontsLoaded = false;
async function loadFontBlobUrls(): Promise<void> {
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<string, { light: ThemeColors; dark: ThemeColors }> =
{
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<typeof setTimeout> | null,
tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
highlightItems: [] as {
id: string;
text: string;
note: string;
color: string;
cfi: string;
percentage: number;
}[],
noteItems: [] as { id: string; content: string; positionLabel: string }[],
annotationsTab: "highlights" as string,
newNoteText: "",
highlightColors: HIGHLIGHT_COLORS,
selectionPopover: {
open: false,
mode: "create" as "create" | "edit",
x: 0,
y: 0,
text: "",
cfi: "",
id: "",
color: "#ffd54f",
note: "",
noteOpen: false,
},
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<typeof setTimeout> | 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,
};
});
},
init() {
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;
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;
if (this.isPDF) {
this.renderer.setAttribute("interaction-mode", this.interactionMode);
}
this.renderer.addEventListener("zoom", () => {
this.zoomPercent = this.renderer.zoomPercent;
});
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 },
);
}
if (!this.isFixedLayout) {
this.computeChapterPageBoundaries(doc);
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
}
});
// ----- highlight rendering (foliate overlayer pipeline) -----
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({})
}
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 on clicks outside it (iframe clicks
// are covered by the selection tracker's collapsed check).
document.addEventListener("pointerdown", (e: PointerEvent) => {
if (!this.selectionPopover.open) return;
if ((e.target as HTMLElement)?.closest?.("#selection-popover")) return;
this.hideSelectionPopover();
}, { 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;
}) {
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";
// 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) {
this.view
?.addAnnotation({
value: hl.cfi,
color: hl.color,
note: hl.note,
id: hl.id,
})
?.catch?.(() => {});
}
},
mapHighlightRow(r: any) {
return {
id: r.id,
text: r.selection_text ?? "",
note: r.note_text ?? "",
color: r.color ?? "#ffff00",
cfi: r.epubcfi_start ?? "",
percentage: r.percentage_start ?? 0,
};
},
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);
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) return;
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.cfi,
color,
note_text: "",
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
}),
},
);
if (!resp.ok) return;
const row = await resp.json();
this.highlightItems.push(this.mapHighlightRow(row));
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;
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: p.cfi,
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.
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?.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 }) {
if (!hl.cfi) return;
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<string, any> = {
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;
},
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 });
},
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 };
}
},
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;
},
anyDrawerOpen(): boolean {
return this.tocOpen || this.settingsOpen || this.bookmarksOpen;
},
closeDrawers() {
this.tocOpen = false;
this.settingsOpen = false;
this.bookmarksOpen = false;
},
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;
}
},
goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return;
if (item.cfi) {
this.view.goTo(item.cfi);
} else if (item.page != null && item.page > 0) {
// 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();
} catch (_e) {
/* ignore bookmark errors for now */
}
},
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"];
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<string, string> = {
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") 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.selectionPopover.open) {
this.hideSelectionPopover();
} 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 (!typing) {
if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark();
}
},
}));
});
Alpine.start();