feat(reader): shortcuts help modal + desktop edge page-turn zones

Help menu (the reader had a growing shortcut/gesture vocabulary with
no discoverability): a ? topbar button, the '?' key, and F1 open a
glass modal listing navigation, zoom/pan, highlight, and touch
gesture reference — format-aware (fixed-layout/PDF rows appear only
where they apply), Esc closes it first in the dismiss chain.

Desktop edge zones: clickable page-turn strips on the left/right
viewport edges (8% width, 44-72px), desktop only (hover+fine-pointer
media query — touch devices use tap zones, avoiding double paging).
Hovering reveals a chevron arrow and a subtle edge gradient. Zones
disable (pointer-events pass-through) while a fixed-layout page is
zoomed so edge clicks belong to content: panning, selection,
highlight editing. fxZoomed tracks zoom state via the renderer zoom
event, reset/fit actions, and init.
This commit is contained in:
2026-08-18 07:59:26 -04:00
parent 1a07635605
commit 94be6edceb
5 changed files with 254 additions and 13 deletions
+27 -1
View File
@@ -395,6 +395,9 @@ document.addEventListener("alpine:init", () => {
chromeBehavior: "auto-hide" as string,
hideTimer: null as ReturnType<typeof setTimeout> | null,
toolsOpen: false,
helpOpen: false,
pointerFine: true as boolean,
fxZoomed: false as boolean,
tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
@@ -511,6 +514,9 @@ document.addEventListener("alpine:init", () => {
);
},
init() {
this.pointerFine = window
.matchMedia("(hover: hover) and (pointer: fine)")
.matches;
const link = document.getElementById("reader-back");
if (!link) return;
const storageKey = "reader_back";
@@ -637,6 +643,7 @@ document.addEventListener("alpine:init", () => {
);
this.renderer.addEventListener("zoom", () => {
this.zoomPercent = this.renderer.zoomPercent;
this.fxZoomed = this.renderer.zoom != null;
});
this.computeFixedLayoutChapterBoundaries();
this.applyDoublePageSpread();
@@ -880,6 +887,7 @@ document.addEventListener("alpine:init", () => {
if (this.isPDF) {
this.renderer.setAttribute("interaction-mode", this.interactionMode);
}
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
this.initTime = Date.now();
this.fetchReadingSpeed();
this.refreshAnnotations();
@@ -1425,6 +1433,7 @@ document.addEventListener("alpine:init", () => {
this.renderer.resetZoom();
this.renderer.dragOffset = { x: 0, y: 0 };
this.zoomPercent = 100;
this.fxZoomed = false;
},
toggleMagnifier() {
if (!this.isFixedLayout) return;
@@ -1449,6 +1458,7 @@ document.addEventListener("alpine:init", () => {
if (mode === "fit-width" || mode === "fit-page") {
this.renderer.setAttribute("zoom", mode);
this.renderer.dragOffset = { x: 0, y: 0 };
this.fxZoomed = false;
}
},
toggleSpread() {
@@ -1512,6 +1522,16 @@ document.addEventListener("alpine:init", () => {
toggleTools() {
this.toolsOpen = !this.toolsOpen;
},
toggleHelp() {
this.helpOpen = !this.helpOpen;
},
// Desktop edge page-turn zones: fine-pointer devices only, and disabled
// while a fixed-layout page is zoomed (edge clicks then belong to the
// content: panning, text selection, highlight editing).
get edgeZonesActive(): boolean {
if (!this.pointerFine) return false;
return !(this.isFixedLayout && this.fxZoomed);
},
anyDrawerOpen(): boolean {
return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen;
},
@@ -2297,7 +2317,9 @@ document.addEventListener("alpine:init", () => {
else if (k === "-" || k === "_") this.zoomOut();
else if (k === "0") this.resetZoom();
else if (k === "Escape") {
if (this.selectionPopover.open) {
if (this.helpOpen) {
this.helpOpen = false;
} else if (this.selectionPopover.open) {
this.hideSelectionPopover();
} else if (this.toolsOpen) {
this.toolsOpen = false;
@@ -2310,10 +2332,14 @@ document.addEventListener("alpine:init", () => {
this.chromeVisible = !this.chromeVisible;
if (this.hideTimer) clearTimeout(this.hideTimer);
}
} else if (k === "F1") {
event.preventDefault();
this.toggleHelp();
} else if (!typing) {
if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark();
else if (k === "?") this.toggleHelp();
else if (k === "/") {
event.preventDefault();
this.toggleSearch();