Two root causes fixed: 1. Reading theme CSS variables were on document.body, leaking font/color into chrome UI. Now scoped to #reader-viewport so chrome keeps its own theme (system font, --text-primary colors) while the reading area uses reading theme colors/background. 2. getCSS() never received font family, font size, or line height settings. The settings UI (dropdowns, sliders) saved values but they were never injected into the book's shadow DOM. Now getCSS() accepts all four settings and generates proper CSS rules for them. Changes: - Wrap foliate-view in #reader-viewport div (absolute positioned between chrome bars) - getCSS() reads computed style from #reader-viewport, not document.body - getCSS() params expanded: fontFamily, fontSize, lineHeight, justify, hyphenate (removed unused 'spacing') - Added FONT_MAP to translate setting keys to CSS font-family values - applyTheme() targets #reader-viewport instead of document.body - Removed dead #reader-viewport typography rules from foliate-themes.css (shadow DOM doesn't inherit outer styles), kept only background-color
388 lines
12 KiB
TypeScript
388 lines
12 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"',
|
|
};
|
|
|
|
const getCSS = ({
|
|
fontFamily,
|
|
fontSize,
|
|
lineHeight,
|
|
justify,
|
|
hyphenate,
|
|
}: {
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
lineHeight: number;
|
|
justify: boolean;
|
|
hyphenate: boolean;
|
|
}) => {
|
|
const el = document.getElementById("reader-viewport");
|
|
const style = getComputedStyle(el ?? document.body);
|
|
const bg = style.getPropertyValue("--reader-bg").trim() || "#fafafa";
|
|
const text = style.getPropertyValue("--reader-text").trim() || "#1a1a1a";
|
|
const link = style.getPropertyValue("--reader-link").trim() || "#0066cc";
|
|
const selection =
|
|
style.getPropertyValue("--reader-selection").trim() || "#b3d9ff";
|
|
const font = FONT_MAP[fontFamily] ?? '"Literata"';
|
|
return `
|
|
@namespace epub "http://www.idpf.org/2007/ops";
|
|
html {
|
|
color-scheme: light dark;
|
|
background-color: ${bg};
|
|
color: ${text};
|
|
font-family: ${font}, serif;
|
|
font-size: ${fontSize}px;
|
|
line-height: ${lineHeight};
|
|
}
|
|
::selection {
|
|
background-color: ${selection};
|
|
}
|
|
a:link {
|
|
color: ${link};
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
a:link {
|
|
color: ${link};
|
|
}
|
|
}
|
|
p, li, blockquote, dd {
|
|
line-height: ${lineHeight};
|
|
text-align: ${justify ? "justify" : "start"};
|
|
-webkit-hyphens: ${hyphenate ? "auto" : "manual"};
|
|
hyphens: ${hyphenate ? "auto" : "manual"};
|
|
-webkit-hyphenate-limit-before: 3;
|
|
-webkit-hyphenate-limit-after: 2;
|
|
-webkit-hyphenate-limit-lines: 2;
|
|
hanging-punctuation: allow-end last;
|
|
widows: 2;
|
|
}
|
|
[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;
|
|
}
|
|
aside[epub|type~="endnote"],
|
|
aside[epub|type~="footnote"],
|
|
aside[epub|type~="note"],
|
|
aside[epub|type~="rearnote"] {
|
|
display: none;
|
|
}
|
|
img, svg, video {
|
|
max-width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
margin: 0.5em auto;
|
|
}
|
|
p {
|
|
orphans: 3;
|
|
widows: 3;
|
|
}
|
|
blockquote {
|
|
margin: 1em 0;
|
|
padding-left: 1em;
|
|
border-left: 3px solid ${link};
|
|
font-style: italic;
|
|
}
|
|
a {
|
|
color: ${link};
|
|
text-decoration: underline;
|
|
}
|
|
`;
|
|
};
|
|
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[],
|
|
readingTheme: "light" as string,
|
|
readingMode: "light" as string,
|
|
readingFont: "literata" as string,
|
|
fontSize: 16 as number,
|
|
lineHeight: 1.6 as number,
|
|
async initReader(config: {
|
|
mediaItemId: string;
|
|
fileUrl: string;
|
|
formatGroup: string;
|
|
readingDirection: string;
|
|
mangaType: string;
|
|
}) {
|
|
this.settings = await loadSettings();
|
|
if (this.settings) {
|
|
this.readingTheme = this.settings.reading_theme || "light";
|
|
this.readingFont = this.settings.reading_font || "literata";
|
|
this.fontSize = this.settings.font_size || 16;
|
|
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")!;
|
|
viewport.classList.add(`theme-${this.readingTheme}`);
|
|
if (this.readingMode === "dark") {
|
|
viewport.classList.add("dark");
|
|
}
|
|
this.view = document.getElementById("reader-view") as any;
|
|
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;
|
|
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
|
|
this.handleKeydown(ev),
|
|
);
|
|
});
|
|
this.view.addEventListener("relocate", (e: any) => {
|
|
const { fraction, location, pageItem } = e.detail;
|
|
const percent = new Intl.NumberFormat("en", {
|
|
style: "percent",
|
|
}).format(fraction);
|
|
const loc = pageItem
|
|
? `Page ${pageItem.label}`
|
|
: `Loc ${location.current}`;
|
|
this.progressText = `${percent} · ${loc}`;
|
|
this.sliderValue = fraction;
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider) {
|
|
slider.value = fraction;
|
|
slider.title = `${percent} · ${loc}`;
|
|
}
|
|
});
|
|
const slider = document.getElementById(
|
|
"progress-slider",
|
|
) as HTMLInputElement;
|
|
if (slider && this.book.dir) {
|
|
slider.dir = this.book.dir;
|
|
}
|
|
if (this.view.getSectionFractions) {
|
|
const tickMarks = document.getElementById("tick-marks");
|
|
if (tickMarks) {
|
|
for (const fraction of this.view.getSectionFractions()) {
|
|
const option = document.createElement("option");
|
|
option.value = fraction;
|
|
tickMarks.append(option);
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener("keydown", (ev: KeyboardEvent) =>
|
|
this.handleKeydown(ev),
|
|
);
|
|
this.renderer.next();
|
|
},
|
|
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")!;
|
|
viewport.className = "absolute inset-x-0 top-[52px] bottom-[52px]";
|
|
viewport.classList.add(`theme-${this.readingTheme}`);
|
|
if (this.readingMode === "dark") {
|
|
viewport.classList.add("dark");
|
|
}
|
|
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,
|
|
});
|
|
},
|
|
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 */
|
|
}
|
|
},
|
|
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();
|