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:
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
19
apps/web/src/reader/readerGestures.ts
Normal file
19
apps/web/src/reader/readerGestures.ts
Normal 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));
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user