Files
bookhoard/web/src/reader/reader.ts
T
john-okeefe 6aa958c78f
Release / build-and-push (push) Successful in 2m27s
fix(reader): save progress on real position change, not on detected intent
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.

Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:

- The relocate handler records the latest CFI (lastCfi), and the
  baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
  view.init() resolves — i.e. the restored position, or the start of
  the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
  CFI comparison for reflowable books, fraction comparison (1e-4
  epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
  bfcache-resurrected page re-baselines to its frozen position, so the
  anti-clobber property survives: a displayed/restored position can
  still never overwrite a newer device push.

The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
2026-09-11 21:50:03 -04:00

2688 lines
93 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,
isComic: false,
comicFlow: "paged" as string,
fxBrightness: 1 as number,
fxContrast: 1 as number,
fxInvert: false as boolean,
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,
// Any live text selection, host document or content iframe. Fed by
// selectionchange listeners (touch devices); tap zones stand down
// while one exists.
anySelection: false as boolean,
highlightItems: [] as {
id: string;
text: string;
note: string;
color: string;
cfi: string;
cfiEnd: string;
renderCfi: 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: "",
cfiEnd: "",
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;
renameOpen?: boolean;
renameText?: string;
}[],
tocItems: [] as any[],
mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null,
// Position last known to be stored (the restore at open time, or the
// last successful save). Relocations that don't move from it are never
// written back, so a restored position can't clobber a newer device
// push — while swipe/scroll paging (handled inside foliate, with no
// wrapper method to flag) still saves normally.
lastSyncedCfi: "" as string,
lastSyncedFraction: -1 as number,
lastCfi: "" as string,
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;
}) {
this.mediaItemId = config.mediaItemId;
// Reading state (position, bookmarks, annotations) is never baked
// into the rendered page: the web reader is intrinsically tied to
// the server, so it reads all of it from the APIs at open time —
// a device sync between render and open can never be shadowed by a
// stale snapshot.
this.isComic = config.formatGroup === "comic_archive";
// Reading flow for comics is a per-book preference (a webtoon title
// vs. a paged manga volume); read before the renderer is chosen.
this.comicFlow =
localStorage.getItem(`bookhoard:comic-flow:${config.mediaItemId}`) ===
"webtoon"
? "webtoon"
: "paged";
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;
}
this.fxBrightness = this.settings.fx_brightness || 1;
this.fxContrast = this.settings.fx_contrast || 1;
this.fxInvert = this.settings.fx_invert ?? false;
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.applyFxFilter();
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: {},
comic: { flow: this.comicFlow },
});
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);
// Same for selectionchange: a selection inside the iframe must
// cancel armed tap actions and feed the host-surface guard.
doc.addEventListener("selectionchange", () => {
const sel = doc.getSelection();
this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(),
);
});
}
// 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;
let cfiEnd: string;
try {
cfi = this.view.getCFI(index, range);
// Collapse to the end point for a distinct end anchor —
// KOReader sync renders the highlight box from pos0/pos1, and
// pos1 == pos0 would be a degenerate (zero-length) range.
const endRange = range.cloneRange();
endRange.collapse(false);
cfiEnd = this.view.getCFI(index, endRange);
} 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,
cfiEnd,
});
};
doc.addEventListener(
"pointerup",
() => setTimeout(checkSelection, 0),
{ passive: true },
);
// Clicks in the book dismiss the popover in ANY mode: iframe
// events never bubble to the host document (so the host
// outside-click dismiss never sees them), and the collapsed
// check above only covers create mode — edit mode had no
// outside-click path at all, leaving Esc as the only way out.
// Clicking a painted highlight still works: this hides, then
// foliate's show-annotation re-opens it in edit mode.
doc.addEventListener(
"pointerdown",
() => {
if (this.selectionPopover.open)
this.hideSelectionPopover();
},
{ 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;
// Device-synced highlights are painted with a synthesized range
// CFI (renderCfi) while the stored locator stays a point CFI, so a
// click reports the render value — match either or editing
// device highlights is impossible.
const h = this.highlightItems.find(
(x) => x.cfi === value || x.renderCfi === 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,
cfiEnd: h.cfiEnd,
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.lastCfi = cfi || "";
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),
);
// Reading position comes from the database, fetched fresh at open
// (the rendered page carries no snapshot of it).
const saved = await this.fetchSavedLocation();
if (this.isFixedLayout && saved.page != null && saved.page > 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: saved.page - 1 })
} else if (saved.cfi) {
await this.view.init({ lastLocation: saved.cfi })
} else if (saved.percentage != null && saved.percentage > 0) {
await this.view.init({
lastLocation: { fraction: saved.percentage },
})
} else {
await this.view.init({})
}
// The position restored above (or the start of the book on a fresh
// open) is the baseline: only relocations that actually move from
// it may write progress.
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
// 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;
// A bfcache-resurrected page is stale by definition: re-baseline to
// its frozen position so it can't write that back until the user
// actually navigates again.
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
if (e.persisted) {
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction =
this.lastRelocateDetail?.fraction ?? -1;
}
});
this.fetchReadingSpeed();
this.refreshAnnotations();
this.refreshBookmarks();
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);
// Host-document selections (fixed-layout/PDF text layers, margins):
// selectionchange never crosses iframe boundaries, so register per
// surface.
document.addEventListener("selectionchange", () => {
const sel = document.getSelection();
this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(),
);
});
},
// Record selection state and abort any armed tap action: a selection
// appearing right after finger-lift means the "tap" was actually a
// long-press selection engaging, and paging away would destroy the
// gesture the user just made.
noteSelectionActivity(hasSelection: boolean) {
this.anySelection = hasSelection;
if (hasSelection && this.tapZoneTimer) {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
}
},
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0;
let downY = 0;
let downT = 0;
let downId = -1;
let moved = false;
// Android fires contextmenu when a long-press engages text
// selection: that press must never resolve into a tap action, even
// when it was released inside the 500ms tap window (the selection
// engaging and the guard racing is exactly how corner selections
// used to page back instead).
let longPressed = 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;
longPressed = false;
},
{ passive: true },
);
surface.addEventListener("contextmenu", () => {
longPressed = true;
}, { passive: true });
// The browser takes over the gesture (text selection, scroll) with
// pointercancel — no pointerup will follow. Drop the tracked
// pointer so stale state can never match a later touch.
surface.addEventListener(
"pointercancel",
() => {
downId = -1;
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 || longPressed || 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;
// Host-surface blind spot: selections living in content iframes
// (or the host's own fixed-layout text layer) never show in a
// per-surface check — the tracked flag covers them.
if (!isDoc && this.anySelection) 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;
cfiEnd?: 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.cfiEnd = opts.cfiEnd ?? "";
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.renderCfi || 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 */
}
}
const cfiEnd = r.epubcfi_end ?? "";
return {
id: r.id,
text: r.selection_text ?? "",
note: r.note_text ?? "",
color: r.color ?? "#ffff00",
cfi,
cfiEnd,
// Rendering/navigating anchor: device-synced highlights store
// POINT CFIs (epubcfi(/6/N!/4/2[id]/8/1:1)), which resolve to a
// collapsed range and paint nothing. Foliate's overlayer needs a
// RANGE CFI — same shape getCFI() produces natively
// (epubcfi(/6/N!/4/2[id],/8/1:1,/8/1:67)) — synthesized here from
// the stored start and end points when both share a base path.
renderCfi: this.toRangeCfi(cfi, cfiEnd, r.selection_text ?? ""),
percentage: r.percentage_start ?? 0,
pdfPage,
pdfRects,
};
},
// Build a foliate-renderable RANGE CFI from stored (possibly point)
// CFIs. Repairs two stale shapes using the selection text: a missing
// end (old web highlights), and a degenerate end — the device-push
// converter used to fall back to a document-start CFI when the end
// xpointer didn't resolve exactly. In both cases the end is derived
// from the start offset advanced by the text's UTF-16 length (EPUB
// CFI offsets are UTF-16 code units); multi-node selections just fail
// resolution harmlessly and fall back to the point CFI.
toRangeCfi(start: string, end: string, text: string): string {
if (!start) return end || start;
if (start.includes(",")) return start; // already a range CFI
const re =
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/;
const ms = re.exec(start);
if (!ms) return start;
const base = ms[1];
const startLocal = ms[2];
const startOff = ms[3] ? parseInt(ms[3], 10) : -1;
const utf16len = [...(text ?? "")].reduce(
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
0,
);
let endLocal = "";
if (end && !end.includes(",")) {
const me = re.exec(end);
if (me && me[1] === base) {
const endOff = me[3] ? parseInt(me[3], 10) : -1;
// Degenerate: end resolves to the document start (the old
// converter fallback) or sits before the start offset.
const degenerate =
endOff === 0 ||
(startOff >= 0 && endOff >= 0 && endOff < startOff);
if (!degenerate) endLocal = me[2];
}
}
if (!endLocal) {
if (startOff < 0 || utf16len <= 0) return start; // point CFI
const cut = startLocal.lastIndexOf(":");
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`;
}
return `${base},${startLocal},${endLocal})`;
},
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,
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
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.pdfPage >= 0
? ""
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || 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,
epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd,
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);
// The overlay is keyed by the value it was added with; an edit can
// change it (note/text edits change the synthesized range), so
// remove the old paint before re-adding or it ghosts.
const oldValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
const newValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (p.pdfPage < 0 && oldValue && oldValue !== newValue) {
this.view?.deleteAnnotation({ value: oldValue });
}
// 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: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || 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) {
// Delete with the value the overlay was added by: device-synced
// highlights paint a synthesized range, not the stored point CFI.
this.view?.deleteAnnotation({
value: hl.renderCfi || 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;
renderCfi: 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.renderCfi || hl.cfi) {
this.pushBackStack();
this.view
?.showAnnotation({ value: hl.renderCfi || 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 */
}
},
// Fresh reading position from the database — the single source of
// truth at open time. Fails soft to a fresh start: change detection
// against the restored baseline guarantees merely opening (even at
// the wrong spot) can never overwrite the stored position.
async fetchSavedLocation(): Promise<{
cfi?: string;
page?: number;
percentage?: number;
}> {
const token = getToken();
if (!token || !this.mediaItemId) return {};
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
},
);
if (!resp.ok) return {};
const row: any = await resp.json();
const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? "";
const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0;
// The stored percentage is a 0-1 fraction.
const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0;
return {
cfi: typeof cfi === "string" ? cfi : "",
page: typeof page === "number" ? page : 0,
percentage: typeof pct === "number" ? pct : 0,
};
} catch (_e) {
return {};
}
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
// Only an actual change from the last stored position writes
// progress: displaying a restored position must never overwrite a
// newer device push. Swipes and scrolls are handled inside foliate
// with no wrapper method to flag, so position — not intent — is the
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
const currentCfi = cfi || "";
const changed =
currentCfi || this.lastSyncedCfi
? currentCfi !== this.lastSyncedCfi
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
if (!changed) 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),
});
this.lastSyncedCfi = cfi || "";
this.lastSyncedFraction = fraction;
} 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 };
},
// ----- fixed-layout display filters -----
// One CSS var drives everything: ::part(filter) on the foliate-view
// iframes (comics/PDFs) and the webtoon page images alike.
applyFxFilter(save = false) {
const viewport = document.getElementById("reader-viewport");
if (!viewport) return;
const parts = [
`brightness(${this.fxBrightness})`,
`contrast(${this.fxContrast})`,
];
if (this.fxInvert) parts.push("invert(1)");
viewport.style.setProperty("--fx-filter", parts.join(" "));
if (save) {
saveSettings({
fx_brightness: this.fxBrightness,
fx_contrast: this.fxContrast,
fx_invert: this.fxInvert,
});
}
},
// Reading flow is chosen before the renderer is created, so toggling
// reloads the reader (progress restores from the saved page).
setComicFlow(mode: string) {
if (mode !== "paged" && mode !== "webtoon") return;
if (mode === this.comicFlow) return;
localStorage.setItem(`bookhoard:comic-flow:${this.mediaItemId}`, mode);
window.location.reload();
},
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.fxBrightness = 1;
this.fxContrast = 1;
this.fxInvert = false;
this.applyFxFilter(true);
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;
// Auto-label mirrors KOReader's convention ("in <chapter title>")
// so every client names unnamed bookmarks identically; falls back
// to a plain "Bookmark" for fixed-layout or TOC-less books.
const chapter = this.lastRelocateDetail?.tocItem?.label as
| string
| undefined;
const title =
!this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark";
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,
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 */
}
},
startBookmarkRename(bookmark: (typeof this.bookmarkItems)[number]) {
bookmark.renameOpen = true;
bookmark.renameText = bookmark.title;
},
async renameBookmark(bookmark: (typeof this.bookmarkItems)[number]) {
const token = getToken();
const title = (bookmark.renameText ?? "").trim();
if (!token || !this.mediaItemId || !title) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
notes: "",
position: bookmark.positionLabel || "",
}),
},
);
if (resp.ok) {
bookmark.title = title;
}
bookmark.renameOpen = false;
} catch (_e) {
bookmark.renameOpen = false;
}
},
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: the
// field must receive h/l page turns, +/ zoom, and caret arrows.
// Escape stays live so popovers/drawers can still be dismissed from
// the keyboard even mid-note.
const t = event.target as HTMLElement | null;
const typing =
t?.tagName === "INPUT" ||
t?.tagName === "SELECT" ||
t?.tagName === "TEXTAREA" ||
!!t?.isContentEditable;
this.pokeChrome();
if (typing && k !== "Escape") return;
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 (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();