feat(reader): touch & mobile — tap zones, gesture engine, mobile sheets

Phase 2 of the reader redesign:

- Fixed-layout touch engine (foliate-js e9e61d8): pinch-zoom around
  the midpoint, two-finger pan, single-finger pan while zoomed,
  horizontal swipe page-turn at fit (RTL-aware via next()/prev()),
  and double-tap to zoom 2.5x / reset. Touch events forwarded from
  page iframes with converted coordinates; preventDefault only when
  the engine consumes the gesture, so PDF text selection and native
  taps stay intact. touch-action: none on the host and in comic/pdf
  page documents keeps the browser from fighting the engine.
- Tap zones (Kindle-style) for touch devices: tap the outer margins
  to page, center to toggle chrome. Size configurable (10-50%) via
  the revived tap_zone_size setting; toggle via new tap_zones_enabled
  (Behavior section of the settings drawer). Pointer-based + passive
  so drags/swipes/selection never trigger; attached both to the
  viewport and inside every page document (iframe events don't
  bubble); debounced 280ms so double-tap zoom doesn't also page; no
  zone actions while a fixed-layout page is zoomed.
- Drawers become full-width sheets on screens <= 640px.
This commit is contained in:
2026-08-14 16:00:19 -04:00
parent a962342ee0
commit 24ea9d8a38
9 changed files with 134 additions and 3 deletions
+1
View File
@@ -405,6 +405,7 @@ func (s *ReaderService) getDefaultSettings() map[string]interface{} {
"panel_zoom_enabled": true,
"double_page_spread": true,
"pdf_interaction_mode": "select",
"tap_zones_enabled": true,
// Dockable panel defaults
"panel_layout": map[string]interface{}{
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#29bc958",
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e9e61d8",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"highlight.js": "^11.11.1",
+9
View File
@@ -362,6 +362,15 @@ templ ReaderSettingsDrawer() {
<option value="time-left">Time Left</option>
</select>
</label>
<label class="flex items-center mb-2">
<input type="checkbox" name="tap_zones_enabled" x-model="tapZonesEnabled" @change="applyTapZoneSettings()" class="mr-2"/>
Tap Zones <span class="text-xs ml-1" style="color: var(--text-secondary)">(touch)</span>
</label>
<label class="block mb-2" x-show="tapZonesEnabled">
Tap Zone Size
<input type="range" name="tap_zone_size" x-model.number="tapZoneSize" @change="applyTapZoneSettings()" min="10" max="50" class="w-full"/>
<span class="text-xs" x-text="tapZoneSize + '%'" style="color: var(--text-secondary)">30%</span>
</label>
</div>
<!-- Reading Theme (reflowable only) -->
<div class="mb-6" x-show="!isFixedLayout">
+1 -1
View File
@@ -415,7 +415,7 @@ func ReaderSettingsDrawer() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"reader-drawer-body\"><!-- Behavior --><div class=\"mb-6\"><h3 class=\"font-semibold mb-2\">Behavior</h3><label class=\"block mb-2\">Chrome <select name=\"chrome_behavior\" x-model=\"chromeBehavior\" @change=\"applyChromeBehavior()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"auto-hide\">Auto Hide</option> <option value=\"always-visible\">Always Visible</option></select></label> <label class=\"block mb-2\">Progress Mode <select name=\"progress_mode\" x-model=\"progressMode\" @change=\"applyProgressMode()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"pages\">Pages</option> <option value=\"chapter\">Chapter</option> <option value=\"percentage\">Percentage</option> <option value=\"time-left\">Time Left</option></select></label></div><!-- Reading Theme (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><div class=\"flex items-center justify-between mb-2\"><h3 class=\"font-semibold\">Reading Theme</h3><button @click=\"toggleReadingMode()\" class=\"relative w-14 h-7 rounded-full transition-colors duration-200 flex items-center px-1\" :class=\"readingMode === 'dark' ? 'bg-blue-500' : 'bg-gray-400'\" type=\"button\" :aria-label=\"readingMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'\"><span class=\"absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 flex items-center justify-center text-xs\" :class=\"readingMode === 'dark' ? 'translate-x-7' : 'translate-x-0'\" x-text=\"readingMode === 'dark' ? '🌙' : '☀️'\">☀️</span></button></div><div class=\"grid grid-cols-6 gap-2\"><template x-for=\"sw in themeSwatches\" :key=\"sw.value\"><button type=\"button\" @click=\"readingTheme = sw.value; applyTheme()\" class=\"theme-swatch\" :class=\"readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''\" :style=\"'background-color:' + sw.bg + '; color:' + sw.fg\" :title=\"sw.label\" :aria-label=\"'Theme ' + sw.label\" :aria-pressed=\"readingTheme === sw.value\"><span class=\"text-xs font-semibold\">Aa</span></button></template></div></div><!-- Typography (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><h3 class=\"font-semibold mb-2\">Typography</h3><label class=\"block mb-2\">Reading Font <select name=\"reading_font\" x-model=\"readingFont\" @change=\"applyFont()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"literata\">Literata (Default)</option> <option value=\"crimson\">Crimson Text</option> <option value=\"source-serif\">Source Serif 4</option> <option value=\"eb-garamond\">EB Garamond</option> <option value=\"libertinus\">Libertinus Serif</option> <option value=\"noto-serif\">Noto Serif</option> <option value=\"charis-sil\">Charis SIL</option> <option value=\"ibm-plex\">IBM Plex Serif</option></select></label> <label class=\"block mb-2\">Font Size <input type=\"range\" name=\"font_size\" x-model.number=\"fontSize\" @change=\"applyFont()\" min=\"10\" max=\"40\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"fontSize + 'px'\" style=\"color: var(--text-secondary)\">18px</span></label> <label class=\"block mb-2\">Line Height <input type=\"range\" name=\"line_height\" x-model.number=\"lineHeight\" @change=\"applyFont()\" min=\"0.8\" max=\"3.0\" step=\"0.1\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"lineHeight\" style=\"color: var(--text-secondary)\">1.6</span></label></div><!-- Layout (fixed-layout only) --><div class=\"mb-6\" x-show=\"isFixedLayout\"><h3 class=\"font-semibold mb-2\">Layout</h3><label class=\"flex items-center mb-2\"><input type=\"checkbox\" name=\"double_page_spread\" x-model=\"doublePageSpread\" @change=\"applyDoublePageSpread()\" class=\"mr-2\"> Double Page Spread</label></div><div class=\"flex gap-2 mt-6\"><button @click=\"restoreDefaults()\" class=\"flex-1 py-2 border rounded hover:opacity-80\" style=\"border-color: var(--border);\">Restore Defaults</button> <button @click=\"closeDrawers()\" class=\"flex-1 py-2 bg-blue-600 text-white rounded hover:bg-blue-700\">Done</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"reader-drawer-body\"><!-- Behavior --><div class=\"mb-6\"><h3 class=\"font-semibold mb-2\">Behavior</h3><label class=\"block mb-2\">Chrome <select name=\"chrome_behavior\" x-model=\"chromeBehavior\" @change=\"applyChromeBehavior()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"auto-hide\">Auto Hide</option> <option value=\"always-visible\">Always Visible</option></select></label> <label class=\"block mb-2\">Progress Mode <select name=\"progress_mode\" x-model=\"progressMode\" @change=\"applyProgressMode()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"pages\">Pages</option> <option value=\"chapter\">Chapter</option> <option value=\"percentage\">Percentage</option> <option value=\"time-left\">Time Left</option></select></label> <label class=\"flex items-center mb-2\"><input type=\"checkbox\" name=\"tap_zones_enabled\" x-model=\"tapZonesEnabled\" @change=\"applyTapZoneSettings()\" class=\"mr-2\"> Tap Zones <span class=\"text-xs ml-1\" style=\"color: var(--text-secondary)\">(touch)</span></label> <label class=\"block mb-2\" x-show=\"tapZonesEnabled\">Tap Zone Size <input type=\"range\" name=\"tap_zone_size\" x-model.number=\"tapZoneSize\" @change=\"applyTapZoneSettings()\" min=\"10\" max=\"50\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"tapZoneSize + '%'\" style=\"color: var(--text-secondary)\">30%</span></label></div><!-- Reading Theme (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><div class=\"flex items-center justify-between mb-2\"><h3 class=\"font-semibold\">Reading Theme</h3><button @click=\"toggleReadingMode()\" class=\"relative w-14 h-7 rounded-full transition-colors duration-200 flex items-center px-1\" :class=\"readingMode === 'dark' ? 'bg-blue-500' : 'bg-gray-400'\" type=\"button\" :aria-label=\"readingMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'\"><span class=\"absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 flex items-center justify-center text-xs\" :class=\"readingMode === 'dark' ? 'translate-x-7' : 'translate-x-0'\" x-text=\"readingMode === 'dark' ? '🌙' : '☀️'\">☀️</span></button></div><div class=\"grid grid-cols-6 gap-2\"><template x-for=\"sw in themeSwatches\" :key=\"sw.value\"><button type=\"button\" @click=\"readingTheme = sw.value; applyTheme()\" class=\"theme-swatch\" :class=\"readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''\" :style=\"'background-color:' + sw.bg + '; color:' + sw.fg\" :title=\"sw.label\" :aria-label=\"'Theme ' + sw.label\" :aria-pressed=\"readingTheme === sw.value\"><span class=\"text-xs font-semibold\">Aa</span></button></template></div></div><!-- Typography (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><h3 class=\"font-semibold mb-2\">Typography</h3><label class=\"block mb-2\">Reading Font <select name=\"reading_font\" x-model=\"readingFont\" @change=\"applyFont()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"literata\">Literata (Default)</option> <option value=\"crimson\">Crimson Text</option> <option value=\"source-serif\">Source Serif 4</option> <option value=\"eb-garamond\">EB Garamond</option> <option value=\"libertinus\">Libertinus Serif</option> <option value=\"noto-serif\">Noto Serif</option> <option value=\"charis-sil\">Charis SIL</option> <option value=\"ibm-plex\">IBM Plex Serif</option></select></label> <label class=\"block mb-2\">Font Size <input type=\"range\" name=\"font_size\" x-model.number=\"fontSize\" @change=\"applyFont()\" min=\"10\" max=\"40\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"fontSize + 'px'\" style=\"color: var(--text-secondary)\">18px</span></label> <label class=\"block mb-2\">Line Height <input type=\"range\" name=\"line_height\" x-model.number=\"lineHeight\" @change=\"applyFont()\" min=\"0.8\" max=\"3.0\" step=\"0.1\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"lineHeight\" style=\"color: var(--text-secondary)\">1.6</span></label></div><!-- Layout (fixed-layout only) --><div class=\"mb-6\" x-show=\"isFixedLayout\"><h3 class=\"font-semibold mb-2\">Layout</h3><label class=\"flex items-center mb-2\"><input type=\"checkbox\" name=\"double_page_spread\" x-model=\"doublePageSpread\" @change=\"applyDoublePageSpread()\" class=\"mr-2\"> Double Page Spread</label></div><div class=\"flex gap-2 mt-6\"><button @click=\"restoreDefaults()\" class=\"flex-1 py-2 border rounded hover:opacity-80\" style=\"border-color: var(--border);\">Restore Defaults</button> <button @click=\"closeDrawers()\" class=\"flex-1 py-2 bg-blue-600 text-white rounded hover:bg-blue-700\">Done</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+112
View File
@@ -375,6 +375,9 @@ document.addEventListener("alpine:init", () => {
chromeVisible: true,
chromeBehavior: "auto-hide" as string,
hideTimer: null as ReturnType<typeof setTimeout> | null,
tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
progressText: "",
progressLabel: "",
progressMain: "",
@@ -493,6 +496,8 @@ document.addEventListener("alpine:init", () => {
const behavior = this.settings.chrome_behavior || "auto-hide";
this.chromeBehavior =
behavior === "always-visible" ? "always-visible" : "auto-hide";
this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
this.tapZoneSize = this.settings.tap_zone_size || 30;
const mode = this.settings.pdf_interaction_mode;
if (mode === "pan" || mode === "text" || mode === "select") {
this.interactionMode = mode;
@@ -556,6 +561,11 @@ document.addEventListener("alpine:init", () => {
doc.addEventListener("pointermove", () => this.pokeChrome(), {
passive: true,
});
// Tap zones inside the page document (iframe events never bubble
// out to the host document, so the viewport listeners miss them).
if (window.matchMedia("(pointer: coarse)").matches) {
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
}
if (!this.isFixedLayout) {
this.computeChapterPageBoundaries(doc);
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
@@ -625,6 +635,7 @@ document.addEventListener("alpine:init", () => {
this.initTime = Date.now();
this.fetchReadingSpeed();
this.setupChrome();
this.setupTapZones();
},
// Chrome visibility: bars overlay the edge-to-edge reading surface.
// 'auto-hide' keeps them up while the pointer is active and fades them
@@ -662,6 +673,107 @@ document.addEventListener("alpine:init", () => {
else this.pokeChrome();
saveSettings({ chrome_behavior: this.chromeBehavior });
},
// ----- tap zones (touch devices) -----
// Kindle-style: tap left/right margins to page, center to toggle chrome.
// Pointer-based and passive, so drags (pan/selection/swipe) never
// trigger. Attached to the viewport and inside every page document
// (iframe contents don't bubble to the host document).
setupTapZones() {
if (!window.matchMedia("(pointer: coarse)").matches) return;
const vp = document.getElementById("reader-viewport");
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
},
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0;
let downY = 0;
let downT = 0;
let downId = -1;
let moved = false;
surface.addEventListener(
"pointerdown",
(e: PointerEvent) => {
if (!e.isPrimary || e.pointerType === "mouse") return;
downX = e.clientX;
downY = e.clientY;
downT = Date.now();
downId = e.pointerId;
moved = false;
},
{ passive: true },
);
surface.addEventListener(
"pointermove",
(e: PointerEvent) => {
if (e.pointerId !== downId) return;
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 12)
moved = true;
},
{ passive: true },
);
surface.addEventListener(
"pointerup",
(e: PointerEvent) => {
if (e.pointerId !== downId) return;
downId = -1;
if (moved || Date.now() - downT > 500) return;
if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null;
if (
target?.closest?.(
"a, button, input, textarea, select, [contenteditable]",
)
)
return;
const sel = isDoc ? (surface as any).getSelection?.() : null;
if (sel?.toString?.()) return;
// No tap actions while a fixed-layout page is zoomed — taps then
// belong to the content (and double-tap zoom).
if (this.isFixedLayout && this.renderer?.zoom != null) return;
const width =
(isDoc
? (surface as any).documentElement.clientWidth
: (surface as HTMLElement).getBoundingClientRect().width) || 1;
const relX =
(e.clientX -
(isDoc
? 0
: (surface as HTMLElement).getBoundingClientRect().left)) /
width;
this.routeTapDebounced(relX);
},
{ passive: true },
);
},
// A short debounce lets a second tap (double-tap zoom in the fixed-layout
// engine) cancel the zone action instead of also paging.
routeTapDebounced(relX: number) {
if (this.tapZoneTimer) {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
return;
}
this.tapZoneTimer = setTimeout(() => {
this.tapZoneTimer = null;
const zone = this.tapZoneSize / 100;
if (relX < zone) this.goLeft();
else if (relX > 1 - zone) this.goRight();
else this.toggleChromeManually();
}, 280);
},
toggleChromeManually() {
if (this.chromeVisible) {
this.chromeVisible = false;
if (this.hideTimer) clearTimeout(this.hideTimer);
} else {
this.pokeChrome();
}
},
applyTapZoneSettings() {
saveSettings({
tap_zones_enabled: this.tapZonesEnabled,
tap_zone_size: this.tapZoneSize,
});
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
if (Date.now() - this.initTime < 5000) return;
if (this.saveTimeout) clearTimeout(this.saveTimeout);
+1
View File
@@ -80,6 +80,7 @@ export function getDefaultSettings(): ReaderSettings {
margin_width: 20,
double_page_spread: true,
pdf_interaction_mode: "select",
tap_zones_enabled: true,
reading_direction: "ltr",
hardware_acceleration: true,
panel_layout: {
+1
View File
@@ -209,6 +209,7 @@ interface ReaderSettings {
double_page_spread: boolean;
pdf_interaction_mode: "select" | "pan" | "text";
tap_zones_enabled: boolean;
reading_direction: "ltr" | "rtl" | "vertical";
reading_mode: "dark" | "light";
+7
View File
@@ -859,6 +859,13 @@
right: 0;
border-left: 1px solid var(--border);
}
/* Mobile: drawers become full-width sheets */
@media (max-width: 640px) {
.reader-drawer {
width: 100%;
max-width: 100%;
}
}
.reader-drawer-header {
display: flex;
align-items: center;
+1 -1
View File
File diff suppressed because one or more lines are too long