Files
Mangalord/frontend/src/lib/components/TapZone.svelte
MechaCat02 3f1a5e9c41 feat(reader): swipe left/right to turn pages in single mode
Wire horizontal swipe into the mobile tap-zone overlay (single mode only,
so continuous mode keeps native vertical scroll). A leftward swipe past the
threshold turns to the next page, rightward to the previous; a mostly-
vertical drag is ignored so panning a tall page never turns it. The gesture
suppresses the synthesized zone tap so it doesn't double-fire. Swipe
classification is a pure, unit-tested helper; e2e drives real touch pointer
events for horizontal (both directions) and vertical cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:23:34 +02:00

213 lines
7.0 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { swipeDirection } from '$lib/swipe';
let {
onPrev,
onNext,
onToggle,
onLongPress,
testid = 'tap-zone'
}: {
onPrev: () => void;
onNext: () => void;
onToggle: () => void;
/**
* Fires when a touch lingers > 450ms on any zone without
* moving. The viewport-coord `anchor` lets the reader open a
* sheet anchored to the press location. Desktop pointers
* (`pointerType !== 'touch'`) never trigger this — right-click
* is the desktop entry point and lives in the reader.
*/
onLongPress?: (anchor: { x: number; y: number }) => void;
testid?: string;
} = $props();
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE = 8;
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let pressStart: { x: number; y: number } | null = null;
// Swipe origin — tracked for every touch pointerdown (independent of the
// long-press timer) so a horizontal drag can turn the page.
let swipeStart: { x: number; y: number } | null = null;
// Self-expiring suppression: cleared either by an actual click
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
// timer below. The latter matters when the user lifts off the
// tap zone after a long-press (finger slid onto the sheet) — no
// click is synthesized, so without an expiry the flag would leak
// and eat the next legitimate tap.
const SUPPRESS_EXPIRY_MS = 500;
let suppressNextClick = false;
let suppressExpiryTimer: ReturnType<typeof setTimeout> | null = null;
function clearPress() {
if (pressTimer != null) {
clearTimeout(pressTimer);
pressTimer = null;
}
pressStart = null;
}
function clearSuppress() {
suppressNextClick = false;
if (suppressExpiryTimer != null) {
clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = null;
}
}
function onPointerDown(e: PointerEvent) {
if (e.pointerType !== 'touch') return;
// Record the swipe origin for every touch, whether or not long-press
// is wired up.
swipeStart = { x: e.clientX, y: e.clientY };
if (!onLongPress) return;
clearPress();
pressStart = { x: e.clientX, y: e.clientY };
const startX = e.clientX;
const startY = e.clientY;
pressTimer = setTimeout(() => {
pressTimer = null;
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
onLongPress?.({ x: startX, y: startY });
}, LONG_PRESS_MS);
}
function onPointerMove(e: PointerEvent) {
if (pressTimer == null || pressStart == null) return;
const dx = e.clientX - pressStart.x;
const dy = e.clientY - pressStart.y;
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
}
function onPointerUp(e: PointerEvent) {
if (swipeStart && e.pointerType === 'touch') {
const dir = swipeDirection(e.clientX - swipeStart.x, e.clientY - swipeStart.y);
if (dir) {
// A swipe consumed the gesture — suppress the click the
// browser may synthesize on the zone so it doesn't also fire
// that zone's tap action (reusing the long-press suppressor).
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
(dir === 'next' ? onNext : onPrev)();
}
}
swipeStart = null;
clearPress();
}
function onPointerCancel() {
swipeStart = null;
clearPress();
}
function onScroll() {
// Scrolling while pressed almost always means the user is
// panning, not deliberately holding. Cancel.
swipeStart = null;
clearPress();
}
function withSuppress(handler: () => void) {
return () => {
if (suppressNextClick) {
clearSuppress();
return;
}
handler();
};
}
// onMount only runs on the client, so the window-scroll listener
// is automatically guarded — no SSR check needed. The teardown
// also clears any in-flight long-press timer.
onMount(() => {
if (!onLongPress) return;
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('scroll', onScroll, true);
clearPress();
clearSuppress();
};
});
</script>
<div class="tap-zones" data-testid={testid}>
<button
type="button"
class="zone left"
onclick={withSuppress(onPrev)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Previous page"
data-testid="{testid}-left"
></button>
<button
type="button"
class="zone center"
onclick={withSuppress(onToggle)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Toggle controls"
data-testid="{testid}-center"
></button>
<button
type="button"
class="zone right"
onclick={withSuppress(onNext)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Next page"
data-testid="{testid}-right"
></button>
</div>
<style>
/* Invisible viewport overlay split into thirds. Mirrors the Mihon
/ Tachiyomi convention: left-third = prev, center-third = toggle
chrome, right-third = next. `pointer-events: none` on the wrap
so unrelated clicks (chrome buttons, sheets) aren't trapped;
each zone re-enables pointer events locally. */
.tap-zones {
position: fixed;
inset: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
pointer-events: none;
}
.zone {
pointer-events: auto;
background: transparent;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
/* No visible style — these are interaction surfaces, not
controls. Focus-visible is honored so keyboard users get
a ring if they tab into one. */
}
.zone:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -4px;
}
</style>