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.
2353 lines
78 KiB
TypeScript
2353 lines
78 KiB
TypeScript
import "foliate-js/view.js";
|
||
import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js";
|
||
import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
|
||
import { Alpine } from "../alpine";
|
||
import { loadSettings, saveSettings } from "./settings-manager";
|
||
import { getToken } from "../storage";
|
||
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();
|