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:
+136
-62
@@ -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();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user