fix(reader): keep footer compact on mobile and clear text under chrome

On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
This commit is contained in:
2026-08-05 21:58:42 -04:00
parent b23f6b0bab
commit 987ece38f0
4 changed files with 117 additions and 73 deletions
+93 -51
View File
@@ -372,6 +372,8 @@ document.addEventListener("alpine:init", () => {
interactionMode: "select" as string,
magnifierEnabled: false,
progressText: "",
progressLabel: "",
progressMain: "",
sliderValue: 0,
settings: null as ReaderSettings | null,
justify: true,
@@ -489,21 +491,20 @@ document.addEventListener("alpine:init", () => {
tocItem,
section,
};
const progressStr = this.formatProgress(
const progressParts = this.formatProgressParts(
fraction,
location,
pageItem,
tocItem,
section,
);
this.progressText = progressStr;
this.setProgress(progressParts);
this.sliderValue = fraction;
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.value = fraction;
slider.title = progressStr;
}
const range = e.detail.range as Range | undefined;
if (range) {
@@ -547,6 +548,34 @@ document.addEventListener("alpine: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;
@@ -774,19 +803,20 @@ document.addEventListener("alpine:init", () => {
/* ignore bookmark errors for now */
}
},
formatProgress(
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 },
): string {
): { 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 percent;
return { label: "", main: percent };
case "chapter": {
if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) {
const totalSections = this.book?.sections?.filter(
@@ -809,10 +839,10 @@ document.addEventListener("alpine:init", () => {
1,
Math.min(currentPage - chapterStart + 1, chapterPages),
);
const label = tocItem.label
? `${tocItem.label} · `
: "";
return `${label}${currentInChapter} / ${chapterPages}`;
return {
label: chapterLabel,
main: `${currentInChapter} / ${chapterPages}`,
};
}
}
if (tocItem && this.chapterBoundaries.length > 0) {
@@ -836,10 +866,10 @@ document.addEventListener("alpine:init", () => {
1,
Math.min(currentPage - chapterStart + 1, chapterPages),
);
const label = tocItem.label
? `${tocItem.label} · `
: "";
return `${label}${currentInChapter} / ${chapterPages}`;
return {
label: chapterLabel,
main: `${currentInChapter} / ${chapterPages}`,
};
}
}
if (section && this.sectionFractionsArr.length > 1) {
@@ -859,16 +889,19 @@ document.addEventListener("alpine:init", () => {
Math.round((fraction - startFrac) * pageBase),
);
const clamped = Math.min(currentInSec, totalInSec);
const label = tocItem?.label
? `${tocItem.label} · `
: "";
return `${label}${clamped} / ${totalInSec}`;
return {
label: chapterLabel,
main: `${clamped} / ${totalInSec}`,
};
}
}
if (location.total > 0) {
return `${percent} · ${location.current} / ${location.total}`;
return {
label: "",
main: `${percent} · ${location.current} / ${location.total}`,
};
}
return percent;
return { label: "", main: percent };
}
case "time-left": {
if (this.readingSpeedPpm > 0 && location.total > 0) {
@@ -877,30 +910,47 @@ document.addEventListener("alpine:init", () => {
if (mins >= 60) {
const hrs = Math.floor(mins / 60);
const m = mins % 60;
return `${percent} · ~${hrs}h ${m}m left`;
return { label: "", main: `${percent} · ~${hrs}h ${m}m left` };
}
return `${percent} · ~${mins} min left`;
return { label: "", main: `${percent} · ~${mins} min left` };
}
return `${percent} · ~-- min left`;
return { label: "", main: `${percent} · ~-- min left` };
}
default: {
if (this.isFixedLayout) {
const pageInfo = this.getRenderedPageInfo();
if (pageInfo) {
return `${pageInfo.current} / ${pageInfo.total}`;
return { label: "", main: `${pageInfo.current} / ${pageInfo.total}` };
}
}
if (pageItem) {
return `${percent} · Page ${pageItem.label}`;
return { label: "", main: `${percent} · Page ${pageItem.label}` };
}
const pageInfoReflow = this.getRenderedPageInfo();
if (pageInfoReflow) {
return `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`;
return {
label: "",
main: `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`,
};
}
return `${percent} · ${location.current} / ${location.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);
@@ -909,19 +959,15 @@ document.addEventListener("alpine:init", () => {
if (this.lastRelocateDetail) {
const { fraction, location, pageItem, tocItem, section } =
this.lastRelocateDetail;
this.progressText = this.formatProgress(
fraction,
location,
pageItem,
tocItem,
section,
this.setProgress(
this.formatProgressParts(
fraction,
location,
pageItem,
tocItem,
section,
),
);
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.title = this.progressText;
}
}
},
async fetchReadingSpeed() {
@@ -944,19 +990,15 @@ document.addEventListener("alpine:init", () => {
if (this.lastRelocateDetail) {
const { fraction, location, pageItem, tocItem, section } =
this.lastRelocateDetail;
this.progressText = this.formatProgress(
fraction,
location,
pageItem,
tocItem,
section,
this.setProgress(
this.formatProgressParts(
fraction,
location,
pageItem,
tocItem,
section,
),
);
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.title = this.progressText;
}
}
},
computeFixedLayoutChapterBoundaries() {