Ouvrir rapidement un très grand livre et continuer la lecture sans attendre le chargement complet sur très mauvaise connexion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
import { ArrowLeft, ArrowRight, Maximize2, Minimize2 } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
|
import { ErrorRibbon } from "../components/ui";
|
|
import { navigate } from "../router";
|
|
import {
|
|
enterElementFullscreen,
|
|
exitElementFullscreen,
|
|
isElementFullscreen,
|
|
isReaderHeaderActive,
|
|
readerNavigationAction,
|
|
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";
|
|
import type { ReadingConnectionMode } from "../api/types";
|
|
|
|
export type ReaderControls = {
|
|
canPrevious: boolean;
|
|
canNext: boolean;
|
|
positionLabel: string;
|
|
onPrevious: () => void;
|
|
onNext: () => void;
|
|
};
|
|
|
|
export type ReaderZoomControls = {
|
|
zoom: number;
|
|
onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void;
|
|
onZoomOut: () => void;
|
|
onZoomIn: () => void;
|
|
onZoomReset: () => void;
|
|
};
|
|
|
|
export type ReaderModeControls = {
|
|
mode: ReaderMode;
|
|
onModeChange: (mode: ReaderMode) => void;
|
|
};
|
|
|
|
export type ReaderConnectionControls = {
|
|
mode: ReadingConnectionMode;
|
|
onModeChange: (mode: ReadingConnectionMode) => void;
|
|
};
|
|
|
|
type ReaderShellProps = {
|
|
title: string;
|
|
backHref: string;
|
|
error?: string;
|
|
onRetry?: () => void;
|
|
controls: ReaderControls;
|
|
zoomControls?: ReaderZoomControls;
|
|
modeControls?: ReaderModeControls;
|
|
connectionControls?: ReaderConnectionControls;
|
|
activity?: string;
|
|
children: ReactNode;
|
|
};
|
|
|
|
export type ReaderZoomAnchor = {
|
|
page: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
ratioX: number;
|
|
ratioY: number;
|
|
};
|
|
|
|
type ReaderPinchGesture = {
|
|
distance: number;
|
|
zoom: number;
|
|
anchor: ReaderZoomAnchor | null;
|
|
};
|
|
|
|
function readerTouchPoint(touch: Touch): ReaderPoint {
|
|
return { x: touch.clientX, y: touch.clientY };
|
|
}
|
|
|
|
function readerTouchMidpoint(first: Touch, second: Touch): ReaderPoint {
|
|
return {
|
|
x: (first.clientX + second.clientX) / 2,
|
|
y: (first.clientY + second.clientY) / 2
|
|
};
|
|
}
|
|
|
|
function readerZoomAnchorAt(point: ReaderPoint): ReaderZoomAnchor | null {
|
|
const pageElement = document.elementFromPoint(point.x, point.y)?.closest("[data-reader-page]");
|
|
if (!(pageElement instanceof HTMLElement)) return null;
|
|
const page = Number(pageElement.dataset.readerPage);
|
|
const rect = pageElement.getBoundingClientRect();
|
|
if (!Number.isFinite(page) || rect.width <= 0 || rect.height <= 0) return null;
|
|
return {
|
|
page,
|
|
clientX: point.x,
|
|
clientY: point.y,
|
|
offsetX: point.x - rect.left,
|
|
offsetY: point.y - rect.top,
|
|
ratioX: Math.max(0, Math.min(1, (point.x - rect.left) / rect.width)),
|
|
ratioY: Math.max(0, Math.min(1, (point.y - rect.top) / rect.height))
|
|
};
|
|
}
|
|
|
|
function restoreReaderZoomAnchor(stage: HTMLElement, anchor: ReaderZoomAnchor) {
|
|
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
|
|
if (!target) return;
|
|
const rect = target.getBoundingClientRect();
|
|
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
|
|
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
|
|
}
|
|
|
|
function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomAnchor) {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => restoreReaderZoomAnchor(stage, anchor));
|
|
});
|
|
}
|
|
|
|
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) {
|
|
const readerRef = useRef<HTMLDivElement>(null);
|
|
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<ReaderPinchGesture | 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);
|
|
headerHideTimerRef.current = undefined;
|
|
}, []);
|
|
|
|
const scheduleHeaderHide = useCallback(() => {
|
|
clearHeaderHideTimer();
|
|
if (!fullscreenActive) {
|
|
setHeaderVisible(true);
|
|
return;
|
|
}
|
|
headerHideTimerRef.current = window.setTimeout(() => {
|
|
if (isReaderHeaderActive(headerRef.current, document)) {
|
|
scheduleHeaderHide();
|
|
return;
|
|
}
|
|
setHeaderVisible(false);
|
|
}, 2000);
|
|
}, [clearHeaderHideTimer, fullscreenActive]);
|
|
|
|
const revealHeader = useCallback(() => {
|
|
if (!fullscreenActive) return;
|
|
setHeaderVisible(true);
|
|
scheduleHeaderHide();
|
|
}, [fullscreenActive, scheduleHeaderHide]);
|
|
|
|
const revealHeaderForPassiveGesture = useCallback(() => {
|
|
if (shouldUseTapOnlyHeaderReveal()) return;
|
|
revealHeader();
|
|
}, [revealHeader, shouldUseTapOnlyHeaderReveal]);
|
|
|
|
const toggleFullscreen = useCallback(async () => {
|
|
const readerElement = readerRef.current;
|
|
if (!readerElement) return;
|
|
|
|
if (isElementFullscreen(readerElement, document)) {
|
|
await exitElementFullscreen(document);
|
|
return;
|
|
}
|
|
|
|
if (fallbackFullscreen) {
|
|
setFallbackFullscreen(false);
|
|
return;
|
|
}
|
|
|
|
if (supportsElementFullscreen(readerElement)) {
|
|
try {
|
|
await enterElementFullscreen(readerElement);
|
|
return;
|
|
} catch {
|
|
setFallbackFullscreen(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setFallbackFullscreen(true);
|
|
}, [fallbackFullscreen]);
|
|
|
|
useEffect(() => {
|
|
const syncFullscreen = () => {
|
|
const readerElement = readerRef.current;
|
|
setNativeFullscreen(isElementFullscreen(readerElement, document));
|
|
};
|
|
syncFullscreen();
|
|
document.addEventListener("fullscreenchange", syncFullscreen);
|
|
return () => document.removeEventListener("fullscreenchange", syncFullscreen);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fullscreenActive) {
|
|
setHeaderVisible(true);
|
|
scheduleHeaderHide();
|
|
return;
|
|
}
|
|
clearHeaderHideTimer();
|
|
setHeaderVisible(true);
|
|
}, [clearHeaderHideTimer, fullscreenActive, scheduleHeaderHide]);
|
|
|
|
useEffect(() => clearHeaderHideTimer, [clearHeaderHideTimer]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && fallbackFullscreen) {
|
|
setFallbackFullscreen(false);
|
|
return;
|
|
}
|
|
const mode = modeControls?.mode ?? "horizontal";
|
|
if (!shouldHandleReaderNavigationKey(event, readerRef.current, mode)) return;
|
|
revealHeader();
|
|
const action = readerNavigationAction(event, mode);
|
|
if (mode === "vertical") {
|
|
event.preventDefault();
|
|
stageRef.current?.scrollBy({ top: (action === "next" ? 1 : -1) * Math.max(120, stageRef.current.clientHeight * 0.82), behavior: "smooth" });
|
|
return;
|
|
}
|
|
if (action === "previous" && controls.canPrevious) {
|
|
event.preventDefault();
|
|
controls.onPrevious();
|
|
}
|
|
if (action === "next" && controls.canNext) {
|
|
event.preventDefault();
|
|
controls.onNext();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [controls, fallbackFullscreen, modeControls?.mode, revealHeader]);
|
|
|
|
useEffect(() => {
|
|
const stage = stageRef.current;
|
|
if (!stage || !zoomControls) return;
|
|
|
|
const startPinch = (event: TouchEvent) => {
|
|
if (!isMobileReaderViewport() || event.touches.length !== 2) {
|
|
pinchRef.current = null;
|
|
stage.removeAttribute("data-reader-pinch-active");
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
|
|
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
|
|
stage.dataset.readerPinchActive = "true";
|
|
tapStartRef.current = null;
|
|
pinchRef.current = { distance, zoom: zoomControls.zoom, anchor: readerZoomAnchorAt(midpoint) };
|
|
};
|
|
const movePinch = (event: TouchEvent) => {
|
|
if (!pinchRef.current || event.touches.length !== 2) return;
|
|
event.preventDefault();
|
|
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
|
|
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
|
|
const anchor = pinchRef.current.anchor ? { ...pinchRef.current.anchor, clientX: midpoint.x, clientY: midpoint.y } : null;
|
|
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance), anchor ?? undefined);
|
|
if (anchor) scheduleReaderZoomAnchorRestore(stage, anchor);
|
|
};
|
|
const endPinch = (event: TouchEvent) => {
|
|
if (event.touches.length >= 2) return;
|
|
pinchRef.current = null;
|
|
stage.removeAttribute("data-reader-pinch-active");
|
|
};
|
|
|
|
stage.addEventListener("touchstart", startPinch, { passive: false });
|
|
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={revealHeaderForPassiveGesture}
|
|
onPointerDown={handleReaderPointerDown}
|
|
onPointerMove={handleReaderPointerMove}
|
|
onPointerUp={handleReaderPointerUp}
|
|
onPointerCancel={handleReaderPointerCancel}
|
|
onScrollCapture={revealHeaderForPassiveGesture}
|
|
onWheel={revealHeaderForPassiveGesture}
|
|
>
|
|
<header
|
|
ref={headerRef}
|
|
className="reader-topbar"
|
|
onBlur={scheduleHeaderHide}
|
|
onFocus={() => {
|
|
clearHeaderHideTimer();
|
|
setHeaderVisible(true);
|
|
}}
|
|
onMouseEnter={() => {
|
|
clearHeaderHideTimer();
|
|
setHeaderVisible(true);
|
|
}}
|
|
onMouseLeave={scheduleHeaderHide}
|
|
onPointerDown={() => {
|
|
clearHeaderHideTimer();
|
|
setHeaderVisible(true);
|
|
}}
|
|
>
|
|
<button className="ghost-button reader-back-button" onClick={() => navigate(backHref)} aria-label="Retour">
|
|
<ArrowLeft size={17} />
|
|
<span>Retour</span>
|
|
</button>
|
|
<strong className="reader-title">{title}</strong>
|
|
<span className="reader-position">{controls.positionLabel}</span>
|
|
{modeControls && (
|
|
<label className="reader-mode-control">
|
|
<span>Mode de lecture</span>
|
|
<select value={modeControls.mode} onChange={(event) => modeControls.onModeChange(event.currentTarget.value as ReaderMode)}>
|
|
<option value="horizontal">Page par page</option>
|
|
<option value="vertical">Défilement vertical</option>
|
|
</select>
|
|
</label>
|
|
)}
|
|
{connectionControls && (
|
|
<label className="reader-mode-control reader-connection-control">
|
|
<span>Mode connexion lente</span>
|
|
<select value={connectionControls.mode} onChange={(event) => connectionControls.onModeChange(event.currentTarget.value as ReadingConnectionMode)}>
|
|
<option value="auto">Automatique</option>
|
|
<option value="speed">Toujours privilégier la vitesse</option>
|
|
<option value="quality">Toujours privilégier la qualité</option>
|
|
</select>
|
|
</label>
|
|
)}
|
|
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
|
|
<ArrowLeft size={16} />
|
|
<span>Precedent</span>
|
|
</button>
|
|
<button className="ghost-button reader-nav-button reader-next-button" onClick={controls.onNext} disabled={!controls.canNext} aria-label="Page suivante">
|
|
<span>Suivant</span>
|
|
<ArrowRight size={16} />
|
|
</button>
|
|
{zoomControls && (
|
|
<div className="reader-zoom-controls" aria-label="Zoom du lecteur">
|
|
<button className="ghost-button reader-zoom-button" onClick={zoomControls.onZoomOut} disabled={zoomControls.zoom <= READER_ZOOM_MIN} aria-label="Reduire le zoom">
|
|
-
|
|
</button>
|
|
<span className="reader-zoom-label">{readerZoomLabel(zoomControls.zoom)}</span>
|
|
<button className="ghost-button reader-zoom-button" onClick={zoomControls.onZoomIn} disabled={zoomControls.zoom >= READER_ZOOM_MAX} aria-label="Augmenter le zoom">
|
|
+
|
|
</button>
|
|
<button className="ghost-button reader-zoom-reset" onClick={zoomControls.onZoomReset} disabled={zoomControls.zoom === READER_ZOOM_DEFAULT}>
|
|
Réinitialiser
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button className="ghost-button reader-fullscreen-toggle" onClick={() => void toggleFullscreen()} aria-pressed={fullscreenActive} aria-label={fullscreenActive ? "Quitter le plein écran" : "Plein écran"}>
|
|
{fullscreenActive ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
|
<span>{fullscreenActive ? "Quitter le plein écran" : "Plein écran"}</span>
|
|
</button>
|
|
</header>
|
|
{error && (
|
|
<div className="reader-status">
|
|
<ErrorRibbon message={error} />
|
|
{onRetry && (
|
|
<button className="ghost-button" onClick={onRetry}>
|
|
Reessayer
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!error && activity && (
|
|
<div className="reader-status reader-activity" role="status" aria-live="polite">
|
|
<span>{activity}</span>
|
|
</div>
|
|
)}
|
|
<main className="reader-stage" ref={stageRef}>
|
|
<div className="reader-content">{children}</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|