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>
213 lines
7.0 KiB
Svelte
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>
|