The double_page_spread checkbox in the reader settings panel was inert: it had no Alpine binding, no apply logic, and no persistence. Default was also inconsistent (false in settings-manager, absent from server defaults). - Add doublePageSpread state to the reader Alpine component, loaded from saved settings (default true) - Add applyDoublePageSpread() which sets the renderer's 'spread' attribute to auto/none and persists the setting via saveSettings - Apply the spread attribute during fixed-layout renderer init - Bind the settings checkbox with x-model and @change - Add double_page_spread: true to ReaderService server defaults so new users get the same starting value the client expects - Also improve the PDF pan/select toolbar button: distinct smart- select vs pan icons, highlighted state while pan mode is active, and dynamic tooltips/aria-labels explaining each mode
1138 lines
36 KiB
TypeScript
1138 lines
36 KiB
TypeScript
import "foliate-js/view.js";
|
|
import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js";
|
|
import { Alpine } from "../alpine";
|
|
import { loadSettings, saveSettings } from "./settings-manager";
|
|
import { getToken } from "../storage";
|
|
|
|
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,
|
|
progressText: "",
|
|
progressLabel: "",
|
|
progressMain: "",
|
|
sliderValue: 0,
|
|
settings: null as ReaderSettings | null,
|
|
justify: true,
|
|
hyphenate: true,
|
|
tocOpen: false,
|
|
settingsOpen: false,
|
|
bookmarksOpen: false,
|
|
navigatorOpen: false,
|
|
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;
|
|
}[],
|
|
init() {
|
|
const link = document.getElementById("reader-back");
|
|
if (!link) return;
|
|
const storageKey = "reader_back";
|
|
if (document.referrer) {
|
|
try {
|
|
const ref = new URL(document.referrer);
|
|
if (
|
|
ref.origin === window.location.origin &&
|
|
!ref.pathname.startsWith("/readers/") &&
|
|
ref.pathname !== window.location.pathname
|
|
) {
|
|
sessionStorage.setItem(storageKey, ref.pathname + ref.search);
|
|
}
|
|
} catch {}
|
|
} else {
|
|
sessionStorage.removeItem(storageKey);
|
|
}
|
|
const backUrl =
|
|
sessionStorage.getItem(storageKey) ||
|
|
link.getAttribute("href") ||
|
|
"/dashboard";
|
|
link.addEventListener("click", (e: Event) => {
|
|
e.preventDefault();
|
|
sessionStorage.removeItem(storageKey);
|
|
window.location.href = backUrl;
|
|
});
|
|
},
|
|
async initReader(config: {
|
|
mediaItemId: string;
|
|
fileUrl: string;
|
|
formatGroup: string;
|
|
readingDirection: string;
|
|
mangaType: string;
|
|
savedPercentage?: number;
|
|
savedCfi?: string;
|
|
savedPage?: number;
|
|
savedTotalPages?: number;
|
|
}) {
|
|
this.mediaItemId = config.mediaItemId;
|
|
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;
|
|
if (this.settings.reading_mode) {
|
|
this.readingMode = this.settings.reading_mode;
|
|
} else {
|
|
this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
|
|
}
|
|
}
|
|
const viewport = document.getElementById("reader-viewport")!;
|
|
const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
|
|
const themeColors =
|
|
this.readingMode === "dark" ? themeSet.dark : themeSet.light;
|
|
viewport.style.backgroundColor = themeColors.bg;
|
|
this.view = document.getElementById("reader-view") as any;
|
|
await loadFontBlobUrls();
|
|
const resp = await fetch(config.fileUrl, {
|
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
});
|
|
const blob = await resp.blob();
|
|
const fileName = new URL(config.fileUrl, window.location.origin).pathname;
|
|
const file = new File([blob], fileName, { type: blob.type });
|
|
await this.view.open(file, {
|
|
pdf: {},
|
|
});
|
|
this.renderer = this.view.renderer;
|
|
this.book = this.view.book;
|
|
this.isFixedLayout = this.view.isFixedLayout;
|
|
if (this.isFixedLayout) {
|
|
// Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl",
|
|
// but makeComicBook never sets dir. Apply it from metadata so RTL page
|
|
// turns (and spread ordering) read right-to-left. Reflowable EPUBs keep
|
|
// whatever direction foliate read from the OPF.
|
|
if (config.readingDirection === "rtl" && this.book.dir !== "rtl") {
|
|
this.book.dir = "rtl";
|
|
}
|
|
this.isPDF = (this.renderer as any).isPDF;
|
|
this.renderer.addEventListener("zoom", () => {
|
|
this.zoomPercent = this.renderer.zoomPercent;
|
|
});
|
|
this.computeFixedLayoutChapterBoundaries();
|
|
this.applyDoublePageSpread();
|
|
} else {
|
|
this.renderer.setStyles?.(this.buildCSS());
|
|
}
|
|
this.view.addEventListener("load", (e: any) => {
|
|
const { doc, index } = e.detail;
|
|
const link = doc.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = "/static/reader-fonts.css";
|
|
doc.head.append(link);
|
|
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
|
|
this.handleKeydown(ev),
|
|
);
|
|
if (!this.isFixedLayout) {
|
|
this.computeChapterPageBoundaries(doc);
|
|
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
|
|
}
|
|
});
|
|
this.view.addEventListener("relocate", (e: any) => {
|
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
|
e.detail;
|
|
this.lastRelocateDetail = {
|
|
fraction,
|
|
location,
|
|
pageItem,
|
|
tocItem,
|
|
section,
|
|
};
|
|
const progressParts = this.formatProgressParts(
|
|
fraction,
|
|
location,
|
|
pageItem,
|
|
tocItem,
|
|
section,
|
|
);
|
|
this.setProgress(progressParts);
|
|
this.sliderValue = fraction;
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider) {
|
|
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);
|
|
});
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider && this.book.dir) {
|
|
slider.dir = this.book.dir;
|
|
}
|
|
if (this.view.getSectionFractions) {
|
|
this.sectionFractionsArr = this.view.getSectionFractions();
|
|
const tickMarks = document.getElementById("tick-marks");
|
|
if (tickMarks) {
|
|
for (const fraction of this.sectionFractionsArr) {
|
|
const option = document.createElement("option");
|
|
option.value = fraction;
|
|
tickMarks.append(option);
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener("keydown", (ev: KeyboardEvent) =>
|
|
this.handleKeydown(ev),
|
|
);
|
|
if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) {
|
|
// Fixed-layout & comics: a page index is the exact, universal locator.
|
|
// A bare number navigates directly to the section index in foliate.
|
|
await this.view.init({ lastLocation: config.savedPage - 1 })
|
|
} else if (config.savedCfi) {
|
|
await this.view.init({ lastLocation: config.savedCfi })
|
|
} else if (config.savedPercentage && config.savedPercentage > 0) {
|
|
await this.view.init({
|
|
lastLocation: { fraction: config.savedPercentage },
|
|
})
|
|
} else {
|
|
await this.view.init({})
|
|
}
|
|
this.initTime = Date.now();
|
|
this.fetchReadingSpeed();
|
|
this.setupViewportInsets();
|
|
},
|
|
updateViewportInsets() {
|
|
const top = document.getElementById("reader-topbar");
|
|
const bot = document.getElementById("reader-bottombar");
|
|
const vp = document.getElementById("reader-viewport");
|
|
if (!top || !bot || !vp) return;
|
|
const margin = 6;
|
|
vp.style.setProperty("top", `${top.offsetHeight + margin}px`);
|
|
vp.style.setProperty("bottom", `${bot.offsetHeight + margin}px`);
|
|
},
|
|
setupViewportInsets() {
|
|
const top = document.getElementById("reader-topbar");
|
|
const bot = document.getElementById("reader-bottombar");
|
|
this.updateViewportInsets();
|
|
window.addEventListener("resize", () => this.updateViewportInsets());
|
|
window.addEventListener("orientationchange", () =>
|
|
setTimeout(() => this.updateViewportInsets(), 250),
|
|
);
|
|
if (
|
|
typeof ResizeObserver !== "undefined" &&
|
|
top &&
|
|
bot
|
|
) {
|
|
const ro = new ResizeObserver(() => this.updateViewportInsets());
|
|
ro.observe(top);
|
|
ro.observe(bot);
|
|
}
|
|
},
|
|
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.tocItem === tocItem,
|
|
);
|
|
if (boundaryIdx !== -1) {
|
|
body.chapter = boundaryIdx;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.isFixedLayout && this.renderer?.zoomPercent) {
|
|
body.zoom_level = this.renderer.zoomPercent / 100;
|
|
}
|
|
|
|
try {
|
|
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (_e) {
|
|
// silent fail — progress save is non-critical
|
|
}
|
|
},
|
|
zoomIn() {
|
|
if (!this.isFixedLayout) return;
|
|
const newScale = Math.min(10, this.renderer.currentScale * 1.2);
|
|
this.renderer.setAttribute("zoom", newScale);
|
|
this.zoomPercent = this.renderer.zoomPercent;
|
|
},
|
|
zoomOut() {
|
|
if (!this.isFixedLayout) return;
|
|
const newScale = Math.max(0.1, this.renderer.currentScale / 1.2);
|
|
this.renderer.setAttribute("zoom", newScale);
|
|
this.zoomPercent = this.renderer.zoomPercent;
|
|
},
|
|
resetZoom() {
|
|
if (!this.isFixedLayout) return;
|
|
this.renderer.resetZoom();
|
|
this.renderer.dragOffset = { x: 0, y: 0 };
|
|
this.zoomPercent = 100;
|
|
},
|
|
toggleMagnifier() {
|
|
if (!this.isFixedLayout) return;
|
|
this.renderer.toggleMagnifier();
|
|
this.magnifierEnabled = this.renderer.zoomMagnifierEnabled;
|
|
},
|
|
toggleInteractionMode() {
|
|
if (!this.isFixedLayout) return;
|
|
const current =
|
|
this.renderer.getAttribute("interaction-mode") || "select";
|
|
const next = current === "select" ? "pan" : "select";
|
|
this.renderer.setAttribute("interaction-mode", next);
|
|
this.interactionMode = next;
|
|
},
|
|
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() {
|
|
this.tocOpen = !this.tocOpen;
|
|
if (this.tocOpen && this.tocItems.length === 0 && this.book?.toc) {
|
|
this.tocItems = this.flattenTOC(this.book.toc);
|
|
}
|
|
},
|
|
toggleSettings() {
|
|
this.settingsOpen = !this.settingsOpen;
|
|
},
|
|
toggleBookmarks() {
|
|
this.bookmarksOpen = !this.bookmarksOpen;
|
|
},
|
|
toggleNavigator() {
|
|
this.navigatorOpen = !this.navigatorOpen;
|
|
},
|
|
toggleWindowShade(panelEl: HTMLElement) {
|
|
panelEl.classList.toggle("panel-collapsed");
|
|
},
|
|
flattenTOC(items: any[], depth = 0): any[] {
|
|
const result: any[] = [];
|
|
for (const item of items) {
|
|
result.push({ ...item, depth });
|
|
if (item.subitems?.length) {
|
|
result.push(...this.flattenTOC(item.subitems, depth + 1));
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
goToTOCItem(item: any) {
|
|
if (this.view && item.href) {
|
|
this.view.goTo(item.href);
|
|
this.tocOpen = false;
|
|
}
|
|
},
|
|
goToBookmarkTarget(cfi: string) {
|
|
if (this.view && cfi) {
|
|
this.view.goTo(cfi);
|
|
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.applyTheme();
|
|
this.applyFont();
|
|
},
|
|
async addBookmark() {
|
|
const token = getToken();
|
|
if (!token || !this.view) return;
|
|
const location = this.view.lastLocation;
|
|
if (!location) return;
|
|
try {
|
|
await fetch("/readers/bookmarks", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
title: `Bookmark at ${this.progressText}`,
|
|
position: JSON.stringify(location),
|
|
}),
|
|
});
|
|
} catch (_e) {
|
|
/* ignore bookmark errors for now */
|
|
}
|
|
},
|
|
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}`,
|
|
};
|
|
}
|
|
}
|
|
},
|
|
setProgress(parts: { label: string; main: string }, sliderTitle?: string) {
|
|
this.progressLabel = parts.label;
|
|
this.progressMain = parts.main;
|
|
this.progressText = parts.label + parts.main;
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider) {
|
|
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;
|
|
if (k === "ArrowLeft" || k === "h") this.goLeft();
|
|
else if (k === "ArrowRight" || k === "l") this.goRight();
|
|
else if (k === "+" || k === "=") this.zoomIn();
|
|
else if (k === "-" || k === "_") this.zoomOut();
|
|
else if (k === "0") this.resetZoom();
|
|
else if (k === "Escape") {
|
|
if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
|
|
this.toggleMagnifier();
|
|
}
|
|
}
|
|
},
|
|
}));
|
|
});
|
|
|
|
Alpine.start();
|