feat(reader): mobile-pattern toolbar — compact row + ⋯ overflow menu under 768px

Wrapping alone isn't how polished mobile readers work. Adopt the
standard pattern (Kindle/Apple Books/Mihon) responsively:

- >= 768px: the full fixed-layout toolbar stays (wrap still absorbs
  mid-size widths) — power users keep one-click zoom/fit/spread.
- < 768px: single-line compact row — page back, back-to-location pin,
  slider, page forward, progress, and a ⋯ overflow button. No
  wrapping, no horizontal scroll.
- ⋯ opens a glass menu anchored above the bar with LABELED rows
  (Zoom −/%/+, Fit, Page position/Recenter, Magnifier, Pointer
  Smart/Pan/Text, Double page, Contents) — labels beat mystery icons
  on touch. Pointer row hides for comics; menu scrolls if tall.
- Dismissal: Esc, outside click (⋯ button exempt so it re-toggles
  cleanly), opening any drawer or TOC closes it; hides with the
  chrome. Compact slider registered in progressSliders() so all
  three stay in sync with relocate events.
This commit is contained in:
2026-08-17 13:35:14 -04:00
parent f283903e2b
commit 6cd0fb226a
5 changed files with 189 additions and 13 deletions
+24 -6
View File
@@ -389,6 +389,7 @@ document.addEventListener("alpine:init", () => {
chromeVisible: true,
chromeBehavior: "auto-hide" as string,
hideTimer: null as ReturnType<typeof setTimeout> | null,
toolsOpen: false,
tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
@@ -879,12 +880,19 @@ document.addEventListener("alpine:init", () => {
document.addEventListener("pointermove", () => this.pokeChrome(), {
passive: true,
});
// Dismiss the selection popover on clicks outside it (iframe clicks
// are covered by the selection tracker's collapsed check).
// 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) => {
if (!this.selectionPopover.open) return;
if ((e.target as HTMLElement)?.closest?.("#selection-popover")) return;
this.hideSelectionPopover();
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;
@@ -1488,6 +1496,9 @@ document.addEventListener("alpine:init", () => {
);
}
},
toggleTools() {
this.toolsOpen = !this.toolsOpen;
},
anyDrawerOpen(): boolean {
return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen;
},
@@ -1496,6 +1507,7 @@ document.addEventListener("alpine:init", () => {
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,
@@ -1977,7 +1989,11 @@ document.addEventListener("alpine:init", () => {
// 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"];
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);
@@ -2137,6 +2153,8 @@ document.addEventListener("alpine:init", () => {
else if (k === "Escape") {
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) {