Files
bookhoard/web/src/reader/reader.ts
T
john-okeefe 94be6edceb feat(reader): shortcuts help modal + desktop edge page-turn zones
Help menu (the reader had a growing shortcut/gesture vocabulary with
no discoverability): a ? topbar button, the '?' key, and F1 open a
glass modal listing navigation, zoom/pan, highlight, and touch
gesture reference — format-aware (fixed-layout/PDF rows appear only
where they apply), Esc closes it first in the dismiss chain.

Desktop edge zones: clickable page-turn strips on the left/right
viewport edges (8% width, 44-72px), desktop only (hover+fine-pointer
media query — touch devices use tap zones, avoiding double paging).
Hovering reveals a chevron arrow and a subtle edge gradient. Zones
disable (pointer-events pass-through) while a fixed-layout page is
zoomed so edge clicks belong to content: panning, selection,
highlight editing. fxZoomed tracks zoom state via the renderer zoom
event, reset/fit actions, and init.
2026-08-18 07:59:26 -04:00

2353 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, HTMLCanvasElement>();
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,
toolsOpen: false,
helpOpen: false,
pointerFine: true as boolean,
fxZoomed: false as boolean,
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;
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<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,
};
});
},
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 <html> 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<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;
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<number, typeof hits>();
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<HTMLElement>(".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<HTMLElement>(
".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<void>((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<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") {
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();