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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user