feat(reader): immersive chrome, slide-over drawers, tri-state PDF pointer mode

Phase 1 of the reader redesign:

- Reading surface is edge-to-edge; top/bottom bars overlay
  translucently (backdrop-blur) instead of reserving insets, killing
  the inset-coordination bug class entirely. Chrome auto-hides after
  2.5s of pointer inactivity (chrome_behavior setting finally wired:
  auto-hide / always-visible; legacy values map to auto-hide). Pointer
  activity inside page iframes keeps it awake; Esc toggles.
- TOC / Settings / Bookmarks become slide-over drawers with a scrim
  (z-50, full-height, safe-area aware), replacing the dockable-panel
  system and its window-shade headers. Only one drawer opens at a
  time; Esc or scrim click closes.
- Bottom bar is contextual: reflowable keeps nav/slider/progress/TOC;
  fixed-layout row adds Fit Page/Width select, zoom cluster,
  magnifier (now shows active state), Double Page Spread toggle, and
  a Smart | Pan | Text segmented control replacing the cryptic
  two-state icon. Smart = text-aware drag; Text = selection-only
  (manual smart-detect off); Pan = force pan. Choice persists via
  pdf_interaction_mode (new setting + foliate 29bc958 'text' mode).
- Settings drawer: Behavior (chrome, progress mode), Appearance with
  18 Kindle-style theme swatches (single source of truth from
  THEME_COLORS), Typography, Layout — each scoped by format.
- Keyboard: t/s/b open TOC/settings/bookmark, Esc closes drawers
  before toggling chrome, shortcuts skip form inputs; both slider
  rows tracked correctly (no duplicate-ID lookups).
- Topbar: Back, title, add-bookmark, bookmarks drawer, Aa settings;
  chrome follows user theme.
This commit is contained in:
2026-08-14 14:59:51 -04:00
parent ba95cc3e8b
commit 612f888683
8 changed files with 678 additions and 445 deletions
+136 -62
View File
@@ -372,6 +372,9 @@ document.addEventListener("alpine:init", () => {
interactionMode: "select" as string,
magnifierEnabled: false,
doublePageSpread: true as boolean,
chromeVisible: true,
chromeBehavior: "auto-hide" as string,
hideTimer: null as ReturnType<typeof setTimeout> | null,
progressText: "",
progressLabel: "",
progressMain: "",
@@ -414,6 +417,23 @@ document.addEventListener("alpine:init", () => {
label: string;
startPage: number;
}[],
get themeSwatches(): {
value: string;
label: string;
bg: string;
fg: string;
}[] {
const dark = this.readingMode === "dark";
return Object.entries(THEME_COLORS).map(([value, set]) => {
const colors = dark ? set.dark : set.light;
return {
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
bg: colors.bg,
fg: colors.fg,
};
});
},
init() {
const link = document.getElementById("reader-back");
if (!link) return;
@@ -470,6 +490,13 @@ document.addEventListener("alpine:init", () => {
this.fontSize = this.settings.font_size || 18;
this.lineHeight = this.settings.line_height || 1.6;
this.doublePageSpread = this.settings.double_page_spread ?? true;
const behavior = this.settings.chrome_behavior || "auto-hide";
this.chromeBehavior =
behavior === "always-visible" ? "always-visible" : "auto-hide";
const mode = this.settings.pdf_interaction_mode;
if (mode === "pan" || mode === "text" || mode === "select") {
this.interactionMode = mode;
}
if (this.settings.reading_mode) {
this.readingMode = this.settings.reading_mode;
} else {
@@ -504,6 +531,9 @@ document.addEventListener("alpine:init", () => {
this.book.dir = "rtl";
}
this.isPDF = (this.renderer as any).isPDF;
if (this.isPDF) {
this.renderer.setAttribute("interaction-mode", this.interactionMode);
}
this.renderer.addEventListener("zoom", () => {
this.zoomPercent = this.renderer.zoomPercent;
});
@@ -521,6 +551,11 @@ document.addEventListener("alpine:init", () => {
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev),
);
// Pointer activity inside page iframes should keep the chrome awake
// (fixed-layout pages and EPUB sections are separate documents).
doc.addEventListener("pointermove", () => this.pokeChrome(), {
passive: true,
});
if (!this.isFixedLayout) {
this.computeChapterPageBoundaries(doc);
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
@@ -545,10 +580,7 @@ document.addEventListener("alpine:init", () => {
);
this.setProgress(progressParts);
this.sliderValue = fraction;
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
for (const slider of this.progressSliders()) {
slider.value = fraction;
}
const range = e.detail.range as Range | undefined;
@@ -558,11 +590,10 @@ document.addEventListener("alpine:init", () => {
}
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.book.dir) {
for (const slider of this.progressSliders()) {
slider.dir = this.book.dir;
}
}
if (this.view.getSectionFractions) {
this.sectionFractionsArr = this.view.getSectionFractions();
@@ -593,45 +624,43 @@ document.addEventListener("alpine:init", () => {
}
this.initTime = Date.now();
this.fetchReadingSpeed();
this.setupViewportInsets();
this.setupChrome();
},
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;
const topInset = `${top.offsetHeight + margin}px`;
const bottomInset = `${bot.offsetHeight + margin}px`;
vp.style.setProperty("top", topInset);
vp.style.setProperty("bottom", bottomInset);
// Sidebars (TOC/settings/bookmarks panels) must sit below/above the
// chrome bars, tracking their measured heights (safe-area insets, wrap).
for (const id of ["left-sidebar", "right-sidebar"]) {
const sidebar = document.getElementById(id);
if (sidebar) {
sidebar.style.setProperty("top", topInset);
sidebar.style.setProperty("bottom", bottomInset);
}
// Chrome visibility: bars overlay the edge-to-edge reading surface.
// 'auto-hide' keeps them up while the pointer is active and fades them
// out after a quiet period; 'always-visible' pins them.
setupChrome() {
document.addEventListener("pointermove", () => this.pokeChrome(), {
passive: true,
});
if (this.chromeBehavior === "always-visible") {
this.chromeVisible = true;
return;
}
this.pokeChrome();
},
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),
pokeChrome() {
this.chromeVisible = true;
if (this.chromeBehavior !== "auto-hide") return;
if (this.hideTimer) clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(() => {
if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
this.chromeVisible = false;
}, 2500);
},
chromeHasFocus(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return (
(tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") &&
!!el.closest("#reader-topbar, #reader-bottombar")
);
if (
typeof ResizeObserver !== "undefined" &&
top &&
bot
) {
const ro = new ResizeObserver(() => this.updateViewportInsets());
ro.observe(top);
ro.observe(bot);
}
},
applyChromeBehavior() {
if (this.chromeBehavior !== "auto-hide") this.chromeVisible = true;
else this.pokeChrome();
saveSettings({ chrome_behavior: this.chromeBehavior });
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
if (Date.now() - this.initTime < 5000) return;
@@ -712,13 +741,23 @@ document.addEventListener("alpine:init", () => {
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;
setInteractionMode(mode: string) {
if (!this.isFixedLayout || !this.isPDF) return;
if (mode !== "select" && mode !== "pan" && mode !== "text") return;
this.renderer.setAttribute("interaction-mode", mode);
this.interactionMode = mode;
saveSettings({ pdf_interaction_mode: mode });
},
applyFitMode(mode: string) {
if (!this.isFixedLayout || !this.renderer) return;
if (mode === "fit-width" || mode === "fit-page") {
this.renderer.setAttribute("zoom", mode);
this.renderer.dragOffset = { x: 0, y: 0 };
}
},
toggleSpread() {
this.doublePageSpread = !this.doublePageSpread;
this.applyDoublePageSpread();
},
applyDoublePageSpread() {
if (!this.isFixedLayout || !this.renderer) return;
@@ -744,19 +783,30 @@ document.addEventListener("alpine:init", () => {
this.view?.goToFraction?.(parseFloat(value));
},
toggleTOC() {
this.tocOpen = !this.tocOpen;
if (this.tocOpen && this.tocItems.length === 0 && this.book?.toc) {
const opening = !this.tocOpen;
this.closeDrawers();
this.tocOpen = opening;
if (opening && this.tocItems.length === 0 && this.book?.toc) {
this.tocItems = this.flattenTOC(this.book.toc);
}
},
toggleSettings() {
this.settingsOpen = !this.settingsOpen;
const opening = !this.settingsOpen;
this.closeDrawers();
this.settingsOpen = opening;
},
toggleBookmarks() {
this.bookmarksOpen = !this.bookmarksOpen;
const opening = !this.bookmarksOpen;
this.closeDrawers();
this.bookmarksOpen = opening;
},
toggleWindowShade(panelEl: HTMLElement) {
panelEl.classList.toggle("panel-collapsed");
anyDrawerOpen(): boolean {
return this.tocOpen || this.settingsOpen || this.bookmarksOpen;
},
closeDrawers() {
this.tocOpen = false;
this.settingsOpen = false;
this.bookmarksOpen = false;
},
flattenTOC(items: any[], depth = 0): any[] {
const result: any[] = [];
@@ -846,6 +896,10 @@ document.addEventListener("alpine:init", () => {
this.lineHeight = 1.6;
this.justify = true;
this.hyphenate = true;
this.chromeBehavior = "auto-hide";
this.applyChromeBehavior();
this.interactionMode = "select";
this.setInteractionMode("select");
this.applyTheme();
this.applyFont();
},
@@ -1072,14 +1126,19 @@ 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"];
return ids
.map((id) => document.getElementById(id) as HTMLInputElement | null)
.filter((el): el is HTMLInputElement => !!el);
},
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) {
for (const slider of this.progressSliders()) {
slider.title = sliderTitle ?? this.progressText;
}
},
@@ -1213,15 +1272,30 @@ document.addEventListener("alpine:init", () => {
},
handleKeydown(event: KeyboardEvent) {
const k = event.key;
// Never hijack keys while the user is typing in a form control.
const tag = (event.target as HTMLElement)?.tagName;
const typing =
tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
this.pokeChrome();
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) {
if (this.anyDrawerOpen()) {
this.closeDrawers();
} else if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
this.toggleMagnifier();
} else {
// Toggle chrome without a fresh auto-hide timer.
this.chromeVisible = !this.chromeVisible;
if (this.hideTimer) clearTimeout(this.hideTimer);
}
} else if (!typing) {
if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark();
}
},
}));
+1
View File
@@ -79,6 +79,7 @@ export function getDefaultSettings(): ReaderSettings {
line_height: 1.6,
margin_width: 20,
double_page_spread: true,
pdf_interaction_mode: "select",
reading_direction: "ltr",
hardware_acceleration: true,
panel_layout: {
+2 -1
View File
@@ -189,7 +189,7 @@ interface ReaderSettings {
progress_mode: "pages" | "chapter" | "percentage" | "time-left";
chrome_theme: string;
reading_theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
reading_theme: string;
reading_font:
| "literata"
@@ -208,6 +208,7 @@ interface ReaderSettings {
auto_scroll: boolean;
double_page_spread: boolean;
pdf_interaction_mode: "select" | "pan" | "text";
reading_direction: "ltr" | "rtl" | "vertical";
reading_mode: "dark" | "light";
+59 -20
View File
@@ -725,7 +725,7 @@
border: 1px solid var(--wood-border);
}
/* ---------- Reader ---------- */
/* ---------- Reader chrome & drawers ---------- */
.reader-icon {
display: block;
fill: none;
@@ -735,32 +735,71 @@
stroke-linejoin: round;
}
.dockable-panel {
.drawer-scrim {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
z-index: 45;
}
.reader-drawer {
position: fixed;
top: 0;
bottom: 0;
width: 340px;
max-width: calc(100vw - 2rem);
background-color: var(--bg-secondary);
z-index: 50;
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
.reader-drawer.left {
left: 0;
border-right: 1px solid var(--border);
}
.reader-drawer.right {
right: 0;
border-left: 1px solid var(--border);
}
.reader-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
transition: max-height 0.2s ease-out;
flex-shrink: 0;
}
.reader-drawer-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.reader-seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
.dockable-panel.panel-collapsed .panel-content {
display: none;
.reader-seg button {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
line-height: 1.25rem;
}
.panel-header {
user-select: none;
.reader-seg button.active {
background-color: #2563eb;
color: #ffffff;
}
.panel-header:hover {
background-color: var(--surface-hover);
.theme-swatch {
height: 2.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.panel-container {
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
width: 320px;
max-width: calc(100vw - 1rem);
max-height: 100%;
overflow-y: auto;
}
#right-sidebar .panel-container {
border-right: none;
border-left: 1px solid var(--border);
.theme-swatch:hover {
border-color: #3b82f6;
}
/* ---------- Series stacked covers ---------- */