From d7dfb4654965a0945e7b8bcd311ab235a6ab0c8e Mon Sep 17 00:00:00 2001 From: Git Agent Date: Wed, 26 Aug 2026 13:33:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20lecteur=20mobile=20=E2=80=94=20r?= =?UTF-8?q?=C3=A9v=C3=A9ler=20le=20header=20au=20tap=20uniquement=20en=20v?= =?UTF-8?q?ertical=20sur=20t=C3=A9l=C3=A9phone=20(scroll/wheel=20passifs?= =?UTF-8?q?=20ignor=C3=A9s)=20et=20piloter=20le=20zoom=20au=20pinch=20?= =?UTF-8?q?=C3=A0=20deux=20doigts=20born=C3=A9=20par=20les=20m=C3=AAmes=20?= =?UTF-8?q?limites=20que=20les=20boutons,=20via=20nouveau=20module=20reade?= =?UTF-8?q?rGestures=20(=C3=A9volution=20#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/ReaderPage.tsx | 1 + apps/web/src/reader/ReaderShell.tsx | 107 +++++++++++++++++++++- apps/web/src/reader/readerGestures.ts | 19 ++++ apps/web/src/reader/readerRuntime.test.ts | 21 +++++ 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/reader/readerGestures.ts diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index ab4cb54..003e721 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -179,6 +179,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { supportsZoom ? { zoom, + onZoomChange: (nextZoom) => changeZoom(nextZoom), onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP), onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP), onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT) diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index 57edf60..20479b5 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -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(null); const stageRef = useRef(null); const headerHideTimerRef = useRef(undefined); + const tapStartRef = useRef(null); + const activeTouchPointsRef = useRef(new Map()); + 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) => { + 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) => { + 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) => { + 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) => { + activeTouchPointsRef.current.delete(event.pointerId); + tapStartRef.current = null; + }, []); + return (
thresholdPx; +} + +export function readerPinchZoom(startZoom: number, startDistance: number, currentDistance: number) { + if (startDistance <= 0 || currentDistance <= 0) return clampReaderZoom(startZoom); + return clampReaderZoom(startZoom * (currentDistance / startDistance)); +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index ba67a29..82d2896 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -17,6 +17,7 @@ import { } from "./readerLayout"; import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender"; import { isElementFullscreen, isReaderHeaderActive, isReaderInteractiveTarget, readerArrowAction, readerNavigationAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; +import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance } from "./readerGestures"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from "./readerScroll"; import { readerErrorMessage } from "./ReaderError"; @@ -147,6 +148,15 @@ describe("reader runtime helpers", () => { 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", () => { expect(readableViewportSize({ width: 0, height: 833 })).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%;"); }); + 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", () => { const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement; const regularElement = {} as HTMLElement;