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:
Git Agent
2026-08-26 13:33:12 +02:00
parent c996ae24f5
commit d7dfb46549
4 changed files with 143 additions and 5 deletions

View File

@ -179,6 +179,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
supportsZoom supportsZoom
? { ? {
zoom, zoom,
onZoomChange: (nextZoom) => changeZoom(nextZoom),
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP), onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP), onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT) onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)

View File

@ -1,5 +1,5 @@
import { ArrowLeft, ArrowRight, Maximize2, Minimize2 } from "lucide-react"; 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 { ErrorRibbon } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
import { import {
@ -11,6 +11,7 @@ import {
shouldHandleReaderNavigationKey, shouldHandleReaderNavigationKey,
supportsElementFullscreen supportsElementFullscreen
} from "./readerFullscreen"; } from "./readerFullscreen";
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout"; import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
import type { ReaderMode } from "./readerScroll"; import type { ReaderMode } from "./readerScroll";
@ -24,6 +25,7 @@ export type ReaderControls = {
export type ReaderZoomControls = { export type ReaderZoomControls = {
zoom: number; zoom: number;
onZoomChange: (zoom: number) => void;
onZoomOut: () => void; onZoomOut: () => void;
onZoomIn: () => void; onZoomIn: () => void;
onZoomReset: () => void; onZoomReset: () => void;
@ -50,12 +52,19 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLElement>(null); const stageRef = useRef<HTMLElement>(null);
const headerHideTimerRef = useRef<number | undefined>(undefined); 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 [nativeFullscreen, setNativeFullscreen] = useState(false);
const [fallbackFullscreen, setFallbackFullscreen] = useState(false); const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const [headerVisible, setHeaderVisible] = useState(true); const [headerVisible, setHeaderVisible] = useState(true);
const fullscreenActive = nativeFullscreen || fallbackFullscreen; const fullscreenActive = nativeFullscreen || fallbackFullscreen;
const readerMode = modeControls?.mode ?? "horizontal"; 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(() => { const clearHeaderHideTimer = useCallback(() => {
if (headerHideTimerRef.current === undefined) return; if (headerHideTimerRef.current === undefined) return;
window.clearTimeout(headerHideTimerRef.current); window.clearTimeout(headerHideTimerRef.current);
@ -83,6 +92,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
scheduleHeaderHide(); scheduleHeaderHide();
}, [fullscreenActive, scheduleHeaderHide]); }, [fullscreenActive, scheduleHeaderHide]);
const revealHeaderForPassiveGesture = useCallback(() => {
if (shouldUseTapOnlyHeaderReveal()) return;
revealHeader();
}, [revealHeader, shouldUseTapOnlyHeaderReveal]);
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
const readerElement = readerRef.current; const readerElement = readerRef.current;
if (!readerElement) return; if (!readerElement) return;
@ -160,16 +174,99 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [controls, fallbackFullscreen, modeControls?.mode, revealHeader]); }, [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 ( return (
<div <div
ref={readerRef} ref={readerRef}
className={`reader-page reader-mode-${readerMode}${fallbackFullscreen ? " reader-fullscreen-fallback" : ""}${fullscreenActive ? " reader-fullscreen-active" : ""}${ className={`reader-page reader-mode-${readerMode}${fallbackFullscreen ? " reader-fullscreen-fallback" : ""}${fullscreenActive ? " reader-fullscreen-active" : ""}${
fullscreenActive && !headerVisible ? " reader-header-hidden" : "" fullscreenActive && !headerVisible ? " reader-header-hidden" : ""
}`} }`}
onMouseMove={revealHeader} onMouseMove={revealHeaderForPassiveGesture}
onPointerDown={revealHeader} onPointerDown={handleReaderPointerDown}
onScrollCapture={revealHeader} onPointerMove={handleReaderPointerMove}
onWheel={revealHeader} onPointerUp={handleReaderPointerUp}
onPointerCancel={handleReaderPointerCancel}
onScrollCapture={revealHeaderForPassiveGesture}
onWheel={revealHeaderForPassiveGesture}
> >
<header <header
ref={headerRef} ref={headerRef}

View File

@ -0,0 +1,19 @@
import { clampReaderZoom } from "./readerLayout";
export type ReaderPoint = {
x: number;
y: number;
};
export function readerPointDistance(first: ReaderPoint, second: ReaderPoint) {
return Math.hypot(first.x - second.x, first.y - second.y);
}
export function readerMovedBeyondTap(start: ReaderPoint, current: ReaderPoint, thresholdPx = 10) {
return readerPointDistance(start, current) > thresholdPx;
}
export function readerPinchZoom(startZoom: number, startDistance: number, currentDistance: number) {
if (startDistance <= 0 || currentDistance <= 0) return clampReaderZoom(startZoom);
return clampReaderZoom(startZoom * (currentDistance / startDistance));
}

View File

@ -17,6 +17,7 @@ import {
} from "./readerLayout"; } from "./readerLayout";
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender"; import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
import { isElementFullscreen, isReaderHeaderActive, isReaderInteractiveTarget, readerArrowAction, readerNavigationAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; import { isElementFullscreen, isReaderHeaderActive, isReaderInteractiveTarget, readerArrowAction, readerNavigationAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen";
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance } from "./readerGestures";
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from "./readerScroll"; import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from "./readerScroll";
import { readerErrorMessage } from "./ReaderError"; import { readerErrorMessage } from "./ReaderError";
@ -147,6 +148,15 @@ describe("reader runtime helpers", () => {
expect(zoomReaderSize({ width: 800, height: 600 }, 200)).toEqual({ width: 1600, height: 1200 }); expect(zoomReaderSize({ width: 800, height: 600 }, 200)).toEqual({ width: 1600, height: 1200 });
}); });
it("maps mobile reader gestures without confusing tap, scroll and pinch", () => {
expect(readerPointDistance({ x: 0, y: 0 }, { x: 0, y: 120 })).toBe(120);
expect(readerMovedBeyondTap({ x: 20, y: 20 }, { x: 24, y: 27 })).toBe(false);
expect(readerMovedBeyondTap({ x: 20, y: 20 }, { x: 20, y: 42 })).toBe(true);
expect(readerPinchZoom(100, 100, 130)).toBe(125);
expect(readerPinchZoom(100, 100, 20)).toBe(50);
expect(readerPinchZoom(250, 100, 160)).toBe(300);
});
it("ignores unusable reader viewport measurements", () => { it("ignores unusable reader viewport measurements", () => {
expect(readableViewportSize({ width: 0, height: 833 })).toBeNull(); expect(readableViewportSize({ width: 0, height: 833 })).toBeNull();
expect(readableViewportSize({ width: 1, height: 1 })).toBeNull(); expect(readableViewportSize({ width: 1, height: 1 })).toBeNull();
@ -231,6 +241,17 @@ describe("reader runtime helpers", () => {
expect(styles).toContain(".reader-mode-control select {\n width: 100%;"); expect(styles).toContain(".reader-mode-control select {\n width: 100%;");
}); });
it("keeps mobile vertical header reveal tied to tap instead of passive scroll", () => {
const source = readFileSync(new URL("./ReaderShell.tsx", import.meta.url), "utf8");
expect(source).toContain("shouldUseTapOnlyHeaderReveal");
expect(source).toContain("onScrollCapture={revealHeaderForPassiveGesture}");
expect(source).toContain("onWheel={revealHeaderForPassiveGesture}");
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
expect(source).toContain("readerPinchZoom");
expect(source).toContain("zoomControls.onZoomChange");
});
it("detects fullscreen support and active fullscreen element", () => { it("detects fullscreen support and active fullscreen element", () => {
const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement; const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement;
const regularElement = {} as HTMLElement; const regularElement = {} as HTMLElement;