Connect the existing progress_mode setting dropdown to the reader's progress display. Four modes are now functional: - pages: overall percent + page/location number (default, existing) - chapter: chapter title + page X / Y within current section - percentage: overall percent only - time-left: percent + estimated time remaining via reading speed API The progress display in the bottom bar is now clickable to cycle through modes with immediate visual feedback. The settings dropdown is bound with x-model for persistence. Reading speed is fetched once on init from the backend reading-speed API for time-left estimates.
823 lines
24 KiB
TypeScript
823 lines
24 KiB
TypeScript
import "foliate-js/view.js";
|
|
import { Alpine } from "../alpine";
|
|
import { loadSettings, saveSettings } from "./settings-manager";
|
|
import { getToken } from "../storage";
|
|
|
|
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,
|
|
progressText: "",
|
|
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,
|
|
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,
|
|
async initReader(config: {
|
|
mediaItemId: string;
|
|
fileUrl: string;
|
|
formatGroup: string;
|
|
readingDirection: string;
|
|
mangaType: string;
|
|
savedPercentage?: number;
|
|
savedCfi?: string;
|
|
}) {
|
|
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;
|
|
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);
|
|
this.renderer = this.view.renderer;
|
|
this.book = this.view.book;
|
|
this.isFixedLayout = this.view.isFixedLayout;
|
|
if (this.isFixedLayout) {
|
|
this.isPDF = (this.renderer as any).isPDF;
|
|
this.renderer.addEventListener("zoom", () => {
|
|
this.zoomPercent = this.renderer.zoomPercent;
|
|
});
|
|
} else {
|
|
this.renderer.setStyles?.(this.buildCSS());
|
|
}
|
|
this.view.addEventListener("load", (e: any) => {
|
|
const { doc } = 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),
|
|
);
|
|
});
|
|
this.view.addEventListener("relocate", (e: any) => {
|
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
|
e.detail;
|
|
this.lastRelocateDetail = {
|
|
fraction,
|
|
location,
|
|
pageItem,
|
|
tocItem,
|
|
section,
|
|
};
|
|
const progressStr = this.formatProgress(
|
|
fraction,
|
|
location,
|
|
pageItem,
|
|
tocItem,
|
|
section,
|
|
);
|
|
this.progressText = progressStr;
|
|
this.sliderValue = fraction;
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider) {
|
|
slider.value = fraction;
|
|
slider.title = progressStr;
|
|
}
|
|
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 (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.fetchReadingSpeed();
|
|
},
|
|
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
|
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;
|
|
try {
|
|
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
percentage: fraction,
|
|
current_page: location?.current ?? 0,
|
|
total_pages: location?.total ?? 0,
|
|
epubcfi: cfi || "",
|
|
}),
|
|
});
|
|
} 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;
|
|
},
|
|
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 */
|
|
}
|
|
},
|
|
formatProgress(
|
|
fraction: number,
|
|
location: { current: number; next: number; total: number },
|
|
pageItem: { id: number; label: string; href: string },
|
|
tocItem: FoliateTocItem | null,
|
|
section: { current: number; total: number },
|
|
): string {
|
|
const percent = new Intl.NumberFormat("en", { style: "percent" }).format(
|
|
fraction,
|
|
);
|
|
switch (this.progressMode) {
|
|
case "percentage":
|
|
return percent;
|
|
case "chapter": {
|
|
if (!section || !this.sectionFractionsArr.length) return percent;
|
|
const idx = section.current;
|
|
const startFrac = this.sectionFractionsArr[idx] ?? 0;
|
|
const endFrac = this.sectionFractionsArr[idx + 1] ?? 1;
|
|
const sectionFrac = endFrac - startFrac;
|
|
if (sectionFrac <= 0) return percent;
|
|
const totalInSec = Math.max(
|
|
1,
|
|
Math.round(sectionFrac * location.total),
|
|
);
|
|
const currentInSec = Math.max(
|
|
1,
|
|
Math.round((fraction - startFrac) * location.total),
|
|
);
|
|
const clamped = Math.min(currentInSec, totalInSec);
|
|
const label = tocItem?.label ? `${tocItem.label} · ` : "";
|
|
return `${label}${clamped} / ${totalInSec}`;
|
|
}
|
|
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 `${percent} · ~${hrs}h ${m}m left`;
|
|
}
|
|
return `${percent} · ~${mins} min left`;
|
|
}
|
|
return percent;
|
|
}
|
|
default: {
|
|
const loc = pageItem
|
|
? `Page ${pageItem.label}`
|
|
: `Loc ${location.current}`;
|
|
return `${percent} · ${loc}`;
|
|
}
|
|
}
|
|
},
|
|
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.progressText = this.formatProgress(
|
|
fraction,
|
|
location,
|
|
pageItem,
|
|
tocItem,
|
|
section,
|
|
);
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider) {
|
|
slider.title = this.progressText;
|
|
}
|
|
}
|
|
},
|
|
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 });
|
|
},
|
|
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();
|