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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user