feat(web): lecteur mobile — révéler le header au tap uniquement en vertical sur téléphone (scroll/wheel passifs ignorés) et piloter le zoom au pinch à deux doigts borné par les mêmes limites que les boutons, via nouveau module readerGestures (évolution #41)
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, ArrowRight, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { ErrorRibbon } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
import {
|
||||
@ -11,6 +11,7 @@ import {
|
||||
shouldHandleReaderNavigationKey,
|
||||
supportsElementFullscreen
|
||||
} from "./readerFullscreen";
|
||||
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
|
||||
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
||||
import type { ReaderMode } from "./readerScroll";
|
||||
|
||||
@ -24,6 +25,7 @@ export type ReaderControls = {
|
||||
|
||||
export type ReaderZoomControls = {
|
||||
zoom: number;
|
||||
onZoomChange: (zoom: number) => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomReset: () => void;
|
||||
@ -50,12 +52,19 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const stageRef = useRef<HTMLElement>(null);
|
||||
const headerHideTimerRef = useRef<number | undefined>(undefined);
|
||||
const tapStartRef = useRef<ReaderPoint | null>(null);
|
||||
const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>());
|
||||
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null);
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
||||
const [headerVisible, setHeaderVisible] = useState(true);
|
||||
const fullscreenActive = nativeFullscreen || fallbackFullscreen;
|
||||
const readerMode = modeControls?.mode ?? "horizontal";
|
||||
|
||||
const isMobileReaderViewport = useCallback(() => window.matchMedia("(max-width: 900px) and (pointer: coarse)").matches, []);
|
||||
|
||||
const shouldUseTapOnlyHeaderReveal = useCallback(() => readerMode === "vertical" && isMobileReaderViewport(), [isMobileReaderViewport, readerMode]);
|
||||
|
||||
const clearHeaderHideTimer = useCallback(() => {
|
||||
if (headerHideTimerRef.current === undefined) return;
|
||||
window.clearTimeout(headerHideTimerRef.current);
|
||||
@ -83,6 +92,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
||||
scheduleHeaderHide();
|
||||
}, [fullscreenActive, scheduleHeaderHide]);
|
||||
|
||||
const revealHeaderForPassiveGesture = useCallback(() => {
|
||||
if (shouldUseTapOnlyHeaderReveal()) return;
|
||||
revealHeader();
|
||||
}, [revealHeader, shouldUseTapOnlyHeaderReveal]);
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
const readerElement = readerRef.current;
|
||||
if (!readerElement) return;
|
||||
@ -160,16 +174,99 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [controls, fallbackFullscreen, modeControls?.mode, revealHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage || !zoomControls) return;
|
||||
|
||||
const touchPoint = (touch: Touch): ReaderPoint => ({ x: touch.clientX, y: touch.clientY });
|
||||
const startPinch = (event: TouchEvent) => {
|
||||
if (!isMobileReaderViewport() || event.touches.length !== 2) {
|
||||
pinchRef.current = null;
|
||||
return;
|
||||
}
|
||||
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
|
||||
pinchRef.current = { distance, zoom: zoomControls.zoom };
|
||||
};
|
||||
const movePinch = (event: TouchEvent) => {
|
||||
if (!pinchRef.current || event.touches.length !== 2) return;
|
||||
event.preventDefault();
|
||||
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
|
||||
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance));
|
||||
};
|
||||
const endPinch = (event: TouchEvent) => {
|
||||
if (event.touches.length < 2) pinchRef.current = null;
|
||||
};
|
||||
|
||||
stage.addEventListener("touchstart", startPinch, { passive: true });
|
||||
stage.addEventListener("touchmove", movePinch, { passive: false });
|
||||
stage.addEventListener("touchend", endPinch);
|
||||
stage.addEventListener("touchcancel", endPinch);
|
||||
return () => {
|
||||
stage.removeEventListener("touchstart", startPinch);
|
||||
stage.removeEventListener("touchmove", movePinch);
|
||||
stage.removeEventListener("touchend", endPinch);
|
||||
stage.removeEventListener("touchcancel", endPinch);
|
||||
};
|
||||
}, [isMobileReaderViewport, zoomControls]);
|
||||
|
||||
const handleReaderPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!shouldUseTapOnlyHeaderReveal()) {
|
||||
revealHeader();
|
||||
return;
|
||||
}
|
||||
if (event.pointerType !== "touch" || !event.isPrimary || activeTouchPointsRef.current.size > 0) {
|
||||
tapStartRef.current = null;
|
||||
return;
|
||||
}
|
||||
activeTouchPointsRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
tapStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
},
|
||||
[revealHeader, shouldUseTapOnlyHeaderReveal]
|
||||
);
|
||||
|
||||
const handleReaderPointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!shouldUseTapOnlyHeaderReveal()) return;
|
||||
const current = { x: event.clientX, y: event.clientY };
|
||||
if (activeTouchPointsRef.current.has(event.pointerId)) activeTouchPointsRef.current.set(event.pointerId, current);
|
||||
const tapStart = tapStartRef.current;
|
||||
if (tapStart && readerMovedBeyondTap(tapStart, current)) tapStartRef.current = null;
|
||||
},
|
||||
[shouldUseTapOnlyHeaderReveal]
|
||||
);
|
||||
|
||||
const handleReaderPointerUp = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!shouldUseTapOnlyHeaderReveal()) return;
|
||||
activeTouchPointsRef.current.delete(event.pointerId);
|
||||
const tapStart = tapStartRef.current;
|
||||
tapStartRef.current = null;
|
||||
if (event.pointerType === "touch" && event.isPrimary && tapStart && !readerMovedBeyondTap(tapStart, { x: event.clientX, y: event.clientY })) {
|
||||
revealHeader();
|
||||
}
|
||||
},
|
||||
[revealHeader, shouldUseTapOnlyHeaderReveal]
|
||||
);
|
||||
|
||||
const handleReaderPointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
activeTouchPointsRef.current.delete(event.pointerId);
|
||||
tapStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={readerRef}
|
||||
className={`reader-page reader-mode-${readerMode}${fallbackFullscreen ? " reader-fullscreen-fallback" : ""}${fullscreenActive ? " reader-fullscreen-active" : ""}${
|
||||
fullscreenActive && !headerVisible ? " reader-header-hidden" : ""
|
||||
}`}
|
||||
onMouseMove={revealHeader}
|
||||
onPointerDown={revealHeader}
|
||||
onScrollCapture={revealHeader}
|
||||
onWheel={revealHeader}
|
||||
onMouseMove={revealHeaderForPassiveGesture}
|
||||
onPointerDown={handleReaderPointerDown}
|
||||
onPointerMove={handleReaderPointerMove}
|
||||
onPointerUp={handleReaderPointerUp}
|
||||
onPointerCancel={handleReaderPointerCancel}
|
||||
onScrollCapture={revealHeaderForPassiveGesture}
|
||||
onWheel={revealHeaderForPassiveGesture}
|
||||
>
|
||||
<header
|
||||
ref={headerRef}
|
||||
|
||||
Reference in New Issue
Block a user