mirror of
https://github.com/aphrody-labs/aphrody-m3-reader.git
synced 2026-10-13 19:54:56 +00:00
chore: migrate package sources from Aphrody
This commit is contained in:
1 parent
65088ba097
commit
4bf09d1539
18 files changed
+2608
-2
No files matched your search
@@ -1,2 +1,10 @@
|
||||
# aphrody-m3-reader
|
||||
Aphrody package @aphrody/m3-reader
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
# @aphrody/m3-reader
|
||||
|
||||
Material 3 React readers for paged and vertical manga and illustrated books. This package owns the reader UI, paging, virtual scrolling, zoom, fullscreen, thumbnails, keyboard controls, and lightbox focus. It does not own an asset server, editorial API, persistence, or OCR policy.
|
||||
|
||||
Import `@aphrody/m3-reader/styles.css` once in the host stylesheet. The host must load Tailwind 4 and scan the package's `dist` directory with `@source`, as with other Aphrody M3 component packages. CSS colors use Material 3 `--md-sys-color-*` roles, with readable fallbacks. The reader can also be themed through `--reader-surface`, `--reader-on-surface`, `--reader-accent`, `--reader-on-accent`, and `--reader-outline`.
|
||||
|
||||
`MangaReader` accepts page URLs, direction, adjacent chapter links, an asset resolver, an optional mirror resolver, and callbacks for page addressing. `CaptionedReader` accepts numbered pages, an abortable transcription loader, a transcription renderer, image optimization callbacks, and a preload count. `CollectionReader` owns edition and chapter controls; the host supplies its edition grouping and chapter renderer. The host supplies domain-specific loading and rendering.
|
||||
|
||||
The `/source` export serves TypeScript sources to hosts that transpile the package themselves (Next, Bun); the root export points to the compiled `dist`. Hosts remain responsible for asset routing, API links, reading history, on-demand transcription retrieval, OCR warnings, correction links, and Markdown rendering.
|
||||
@@ -0,0 +1,3 @@
|
||||
import { m3Package } from "@aphrody/m3-config/bun-build";
|
||||
|
||||
export default m3Package();
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["@aphrody/m3-config/test/dom", "./test/setup.ts"]
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@aphrody/m3-reader",
|
||||
"version": "1.4.4",
|
||||
"description": "Material 3 manga and illustrated book readers for React",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"styles.css"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./source": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./styles.css": "./styles.css",
|
||||
"./use-focus-trap": {
|
||||
"types": "./src/use-focus-trap.ts",
|
||||
"default": "./src/use-focus-trap.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun ../../scripts/build.ts build --only $npm_package_name",
|
||||
"typecheck": "bun check --noEmit",
|
||||
"test": "bun test",
|
||||
"typecheck:fast": "bun check --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"swiper": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aphrody/m3-config": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
"tailwindcss": ">=4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Lecteur de planches légendées (recueil illustré, interview) — format manga + légende.
|
||||
*
|
||||
* Deux modes :
|
||||
* - Vertical (défaut) : défilement webtoon, image puis texte en dessous.
|
||||
* - Paginé : une planche à la fois (Swiper). Sur grand écran, layout
|
||||
* planche | texte côte à côte pour que les légendes longues ne mangent
|
||||
* plus l'image. Clic sur la planche → lightbox plein écran.
|
||||
*
|
||||
* Les pages sans image ni texte sont filtrées côté public (slots admin vides).
|
||||
* LTR par défaut. Affiche le `number` éditorial (pas seulement l'index).
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactElement, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useFocusTrap } from "./use-focus-trap";
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ScrollText,
|
||||
X,
|
||||
ZoomIn,
|
||||
} from "lucide-react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Mousewheel, Virtual, Zoom } from "swiper/modules";
|
||||
|
||||
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
||||
import "swiper/css";
|
||||
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
||||
import "swiper/css/zoom";
|
||||
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
||||
import "swiper/css/virtual";
|
||||
|
||||
import {
|
||||
SHOW_STRIP_MAX,
|
||||
type ViewMode,
|
||||
useFullscreen,
|
||||
useSwiperSync,
|
||||
useVerticalScrollSync,
|
||||
virtualRowStyle,
|
||||
} from "./use-reader-core";
|
||||
|
||||
export type CaptionedReaderPage = {
|
||||
/** Numéro affiché (auto 1…N, modifiable côté admin). */
|
||||
number?: number | null;
|
||||
image?: string | null;
|
||||
/**
|
||||
* Transcription — présente pour les premières planches seulement.
|
||||
* Au-delà, la fiche envoie `text: null` et `aDuTexte: true`, et le lecteur
|
||||
* demande le texte au fournisseur de transcriptions au fil du défilement :
|
||||
* embarquer les 313 transcriptions d'un recueil pesait 412 Ko de charge RSC
|
||||
* pour une planche lue.
|
||||
*/
|
||||
text?: string | null;
|
||||
/** La planche PORTE une transcription, même si elle n'est pas encore chargée. */
|
||||
aDuTexte?: boolean;
|
||||
/** Transcription relue à l'image : pas d'avertissement de lecture douteuse. */
|
||||
verifiee?: boolean;
|
||||
};
|
||||
|
||||
export type ReaderTranscription = { text: string; page: number; verified: boolean };
|
||||
|
||||
export type CaptionedReaderProps = {
|
||||
pages: CaptionedReaderPage[];
|
||||
title: string;
|
||||
resolveAssetUrl?: (path: string) => string;
|
||||
imageSrc?: (url: string, width: number) => string;
|
||||
imageSrcSet?: (url: string, widths: number[]) => string | null;
|
||||
loadTranscriptions?: (pages: number[], signal: AbortSignal) => Promise<Record<string, string>>;
|
||||
renderTranscription?: (transcription: ReaderTranscription) => ReactNode;
|
||||
preloadCount?: number;
|
||||
};
|
||||
|
||||
type ResolvedPage = {
|
||||
/** Numéro affiché (édition admin) — pas forcément = position dans le lecteur. */
|
||||
number: number;
|
||||
imageUrl: string | null;
|
||||
text: string | null;
|
||||
/** Une transcription existe côté serveur, chargée ou non. */
|
||||
aDuTexte: boolean;
|
||||
/** Transcription acquittée à la main (drapeau porté par la fiche). */
|
||||
verifiee: boolean;
|
||||
};
|
||||
|
||||
function pageNumber(p: CaptionedReaderPage, fallback: number): number {
|
||||
const raw = p.number as unknown;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return Math.trunc(raw);
|
||||
if (typeof raw === "string" && String(raw).trim() && Number.isFinite(Number(raw))) {
|
||||
return Math.trunc(Number(raw));
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolvePages(
|
||||
pages: CaptionedReaderPage[],
|
||||
resolveAssetUrl: (path: string) => string,
|
||||
): ResolvedPage[] {
|
||||
const out: ResolvedPage[] = [];
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const p = pages[i];
|
||||
const img = typeof p.image === "string" && p.image.trim() ? p.image.trim() : null;
|
||||
const text = typeof p.text === "string" && p.text.trim() ? p.text.trim() : null;
|
||||
const aDuTexte = p.aDuTexte === true || text !== null;
|
||||
// Une planche sans image ET sans transcription est un emplacement vide
|
||||
// réservé côté studio : elle ne se montre pas au public.
|
||||
if (!img && !aDuTexte) continue;
|
||||
out.push({
|
||||
number: pageNumber(p, i + 1),
|
||||
imageUrl: img ? resolveAssetUrl(img) : null,
|
||||
text,
|
||||
aDuTexte,
|
||||
verifiee: p.verifiee === true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Combien de planches autour de la courante on charge d'avance. */
|
||||
const FENETRE_TEXTE = 6;
|
||||
|
||||
/**
|
||||
* Transcriptions chargées à la demande.
|
||||
*
|
||||
* La fiche n'embarque que les premières ; celles qu'on approche sont demandées
|
||||
* par fenêtres au fournisseur transmis par l'hôte. Une planche déjà connue n'est
|
||||
* jamais redemandée, et un échec réseau laisse simplement la planche sans
|
||||
* texte — jamais d'erreur jetée à la figure d'un lecteur qui feuillette.
|
||||
*/
|
||||
function useTextesPlanches(
|
||||
loadTranscriptions: CaptionedReaderProps["loadTranscriptions"],
|
||||
items: ResolvedPage[],
|
||||
current: number,
|
||||
): Map<number, string> {
|
||||
const [charges, setCharges] = useState<Map<number, string>>(() => new Map());
|
||||
// Les numéros déjà demandés (succès ou échec) : sans cette mémoire, une
|
||||
// planche sans transcription serait redemandée à chaque défilement.
|
||||
const demandes = useRef<Set<number>>(new Set());
|
||||
|
||||
// Candidats : planches proches sans texte. Le filtre `demandes` (ref) est
|
||||
// appliqué dans l'effet, jamais pendant le rendu.
|
||||
const manquants = useMemo(() => {
|
||||
if (!loadTranscriptions) return [];
|
||||
const de = Math.max(0, current - FENETRE_TEXTE);
|
||||
const a = Math.min(items.length, current + FENETRE_TEXTE + 1);
|
||||
const out: number[] = [];
|
||||
for (let i = de; i < a; i++) {
|
||||
const it = items[i];
|
||||
if (!it || !it.aDuTexte || it.text !== null) continue;
|
||||
if (charges.has(it.number)) continue;
|
||||
out.push(it.number);
|
||||
}
|
||||
return out;
|
||||
}, [loadTranscriptions, items, current, charges]);
|
||||
|
||||
useEffect(() => {
|
||||
const aCharger = manquants.filter((n) => !demandes.current.has(n));
|
||||
if (aCharger.length === 0) return;
|
||||
for (const n of aCharger) demandes.current.add(n);
|
||||
const stop = new AbortController();
|
||||
loadTranscriptions!(aCharger, stop.signal)
|
||||
.then((textes) => ({ textes }))
|
||||
.then((d: { textes?: Record<string, string> }) => {
|
||||
const recus = Object.entries(d.textes ?? {});
|
||||
if (recus.length === 0) return undefined;
|
||||
setCharges((prec) => {
|
||||
const suiv = new Map(prec);
|
||||
for (const [n, t] of recus) suiv.set(Number(n), t);
|
||||
return suiv;
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
/* hors ligne ou navigation : la planche reste sans texte */
|
||||
});
|
||||
return () => stop.abort();
|
||||
}, [manquants, loadTranscriptions]);
|
||||
|
||||
return charges;
|
||||
}
|
||||
|
||||
// Défauts stables : une fonction littérale par défaut change à chaque rendu.
|
||||
const identiteUrl = (path: string): string => path;
|
||||
const identiteImage = (url: string): string => url;
|
||||
const sansSrcSet = (): null => null;
|
||||
|
||||
export function CaptionedReader({
|
||||
pages,
|
||||
title,
|
||||
resolveAssetUrl = identiteUrl,
|
||||
imageSrc = identiteImage,
|
||||
imageSrcSet = sansSrcSet,
|
||||
loadTranscriptions,
|
||||
renderTranscription,
|
||||
preloadCount = 4,
|
||||
}: CaptionedReaderProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lightboxRef = useRef<HTMLDivElement>(null);
|
||||
const baseId = useId();
|
||||
|
||||
const items = useMemo(() => resolvePages(pages, resolveAssetUrl), [pages, resolveAssetUrl]);
|
||||
const total = items.length;
|
||||
|
||||
const [mode, setMode] = useState<ViewMode>("vertical");
|
||||
const [current, setCurrent] = useState(0);
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
|
||||
const { swiperRef, handleSwiper, handleSlideChange } = useSwiperSync(setCurrent);
|
||||
/** Lightbox plein écran sur la planche courante (indépendant du mode). */
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
|
||||
const textesCharges = useTextesPlanches(loadTranscriptions, items, current);
|
||||
/** Le texte d'une planche : celui de la fiche, sinon celui chargé après coup. */
|
||||
const texteDe = useCallback(
|
||||
(p: ResolvedPage | undefined): string | null =>
|
||||
p ? (p.text ?? textesCharges.get(p.number) ?? null) : null,
|
||||
[textesCharges],
|
||||
);
|
||||
useFocusTrap(lightboxRef, lightbox, () => setLightbox(false));
|
||||
|
||||
// Clamp l'index si le nombre de pages change (édition live / revalidation).
|
||||
useEffect(() => {
|
||||
if (current >= total && total > 0) setCurrent(total - 1);
|
||||
if (total === 0) setCurrent(0);
|
||||
}, [total, current]);
|
||||
|
||||
// Précharge planche courante ± voisines.
|
||||
useEffect(() => {
|
||||
if (total === 0) return;
|
||||
for (const idx of [current - 1, current, current + 1, current + 2]) {
|
||||
const url = items[idx]?.imageUrl;
|
||||
if (!url) continue;
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
}, [current, items, total]);
|
||||
|
||||
// Détection côté client uniquement (`document` absent au rendu serveur).
|
||||
const [fullscreenDispo, setFullscreenDispo] = useState(false);
|
||||
useEffect(() => {
|
||||
setFullscreenDispo(
|
||||
typeof document !== "undefined" &&
|
||||
document.fullscreenEnabled &&
|
||||
typeof containerRef.current?.requestFullscreen === "function",
|
||||
);
|
||||
}, []);
|
||||
|
||||
/* ------------------------------ Vertical ------------------------------ */
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null);
|
||||
// oxlint-disable-next-line react/incompatible-library -- useVirtualizer ne renvoie pas de valeur mémoïsable ; il n'est appelé qu'au rendu, sans passer ses résultats aux dépendances de hooks.
|
||||
const virtualizer = useVirtualizer({
|
||||
count: total,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: () =>
|
||||
typeof window === "undefined" ? 1100 : Math.round(window.innerHeight * 0.95),
|
||||
// Marge de préchargement réglée sur les capacités : 4 planches rendent le
|
||||
// défilement continu sur un scan de 1 à 2 Mo, mais les précharger sur un
|
||||
// mobile en 2G dépense des données pour des pages qu'on ne verra
|
||||
// peut-être jamais. `prechargement` vaut 1 sur réseau lent, 6 sur une
|
||||
// machine confortable.
|
||||
overscan: Math.max(2, preloadCount),
|
||||
});
|
||||
|
||||
// oxlint-disable react-hooks/exhaustive-deps -- swiperRef est une ref stable (renvoyée par useSwiperSync) ; `.current` est lu à l'appel, pas à la création du callback
|
||||
const goTo = useCallback(
|
||||
(idx: number) => {
|
||||
const i = Math.min(Math.max(idx, 0), Math.max(0, total - 1));
|
||||
setCurrent(i);
|
||||
if (mode === "paged") swiperRef.current?.slideTo(i);
|
||||
else virtualizer.scrollToIndex(i, { align: "start" });
|
||||
},
|
||||
[mode, total, virtualizer],
|
||||
);
|
||||
// oxlint-enable react-hooks/exhaustive-deps
|
||||
|
||||
useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent);
|
||||
|
||||
// Navigation clavier des planches.
|
||||
//
|
||||
// Une SEULE source de vérité : ce handler, qui passe par `goTo` (donc
|
||||
// `slideTo` en mode paginé). Le module `Keyboard` de Swiper est
|
||||
// volontairement absent : il écoute lui aussi `keydown`, sur `document`,
|
||||
// et faisait AVANCER DEUX FOIS — Swiper glissait d'une planche et émettait
|
||||
// `slideChange` (`setCurrent(index)`), puis ce handler ajoutait +1 par-dessus.
|
||||
// `current` se décalait durablement de la planche affichée, si bien que la
|
||||
// transcription montrée à côté du scan était celle d'une AUTRE page (mesuré
|
||||
// sur un recueil de 20 planches : compteur « n°3 · 3/20 » sur la planche 2).
|
||||
useEffect(() => {
|
||||
const actif = lightbox || mode === "paged";
|
||||
if (!actif) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const cible = e.target as HTMLElement | null;
|
||||
const tag = cible?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || cible?.isContentEditable) return;
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
if (lightbox) setLightbox(false);
|
||||
return;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
goTo(current - 1);
|
||||
return;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
goTo(current + 1);
|
||||
return;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
goTo(0);
|
||||
return;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
goTo(total - 1);
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [lightbox, mode, total, current, goTo]);
|
||||
|
||||
const goPrevPage = useCallback(() => {
|
||||
goTo(current - 1);
|
||||
}, [current, goTo]);
|
||||
|
||||
const goNextPage = useCallback(() => {
|
||||
goTo(current + 1);
|
||||
}, [current, goTo]);
|
||||
|
||||
const openLightbox = useCallback(
|
||||
(idx?: number) => {
|
||||
if (typeof idx === "number") setCurrent(idx);
|
||||
const item = items[typeof idx === "number" ? idx : current];
|
||||
if (item?.imageUrl) setLightbox(true);
|
||||
},
|
||||
[items, current],
|
||||
);
|
||||
|
||||
// `min-h-10`/`min-w-10` (40 px) : les commandes du lecteur mesuraient 28 à
|
||||
// 30 px de haut, sous le seuil tactile. Le padding reste serré à partir de
|
||||
// `sm`, où l'on pointe à la souris.
|
||||
// Balayage horizontal dans la visionneuse : sur mobile elle n'offrait que deux
|
||||
// flèches de 44 px posées sur l'image — le geste naturel (faire glisser la
|
||||
// planche) ne faisait rien. Seuil de 48 px, et on ignore les gestes
|
||||
// majoritairement verticaux pour ne pas voler un défilement.
|
||||
const toucheRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
toucheRef.current = t ? { x: t.clientX, y: t.clientY } : null;
|
||||
}, []);
|
||||
const onTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const depart = toucheRef.current;
|
||||
toucheRef.current = null;
|
||||
const t = e.changedTouches[0];
|
||||
if (!depart || !t) return;
|
||||
const dx = t.clientX - depart.x;
|
||||
const dy = t.clientY - depart.y;
|
||||
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return;
|
||||
// `goTo` et non `setCurrent` : il fait suivre le Swiper sous-jacent,
|
||||
// sans quoi refermer la visionneuse laisserait la planche paginée sur
|
||||
// l'ancienne page.
|
||||
goTo(dx < 0 ? current + 1 : current - 1);
|
||||
},
|
||||
[current, goTo],
|
||||
);
|
||||
|
||||
const currentItem = items[current];
|
||||
const currentNum = currentItem?.number ?? current + 1;
|
||||
const counter = total ? `n°${currentNum} · ${Math.min(current + 1, total)}/${total}` : "0 / 0";
|
||||
const currentText = texteDe(currentItem);
|
||||
const currentImage = currentItem?.imageUrl ?? null;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="m3-reader-frame flex min-h-[200px] items-center justify-center rounded-xl p-8 text-[18px] text-[var(--reader-on-surface)]/50">
|
||||
Aucune page à afficher pour le moment.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const legendBlock = (
|
||||
<div className="flex h-full min-h-0 flex-col p-4 bg-[var(--reader-surface)] border-t-2 lg:border-t-0 lg:border-l-2 border-[var(--reader-outline)]">
|
||||
<div className="mb-2 shrink-0 flex items-center justify-between">
|
||||
<span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black">
|
||||
<span>PLANCHE {currentNum}</span>
|
||||
</span>
|
||||
<span className="text-[18px] font-bold tabular-nums text-[var(--reader-on-surface)]/60">
|
||||
{current + 1} / {total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1 m3-reader-scroll">
|
||||
{currentText ? (
|
||||
(renderTranscription?.({
|
||||
text: currentText,
|
||||
page: currentNum,
|
||||
verified: currentItem?.verifiee ?? false,
|
||||
}) ?? currentText)
|
||||
) : currentItem?.aDuTexte ? (
|
||||
// La planche PORTE une transcription : dire « aucune description »
|
||||
// pendant qu'elle arrive ferait croire à une lacune du corpus.
|
||||
<p className="text-[18px] italic text-[var(--reader-on-surface)]/50">
|
||||
Chargement de la transcription…
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[18px] italic text-[var(--reader-on-surface)]/50">
|
||||
Aucune description pour cette page.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className="m3-reader m3-reader-container m3-reader-scroll flex h-[84svh] min-h-[460px] w-full min-w-0 flex-col overflow-hidden rounded-xl bg-[var(--reader-surface)] [contain:inline-size] [&:fullscreen]:h-screen [&:fullscreen]:min-h-screen [&:fullscreen]:rounded-none"
|
||||
data-captioned-reader
|
||||
data-m3-component="CaptionedReader"
|
||||
data-testid="captioned-reader"
|
||||
aria-label={title}
|
||||
>
|
||||
{/* Barre de contrôles (HUD) */}
|
||||
<div className="m3-reader-hud z-10 flex flex-nowrap items-center gap-2 px-3 py-2.5 sm:flex-wrap">
|
||||
<span className="mr-1 hidden min-w-0 max-w-[40ch] truncate font-sans text-[18px] font-extrabold uppercase tracking-wide text-[var(--reader-on-surface)] drop-shadow-[1px_1px_0px_#000] sm:block">
|
||||
{title}
|
||||
</span>
|
||||
<span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black sm:ml-auto">
|
||||
<span>{counter}</span>
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "vertical" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
onClick={() => setMode("vertical")}
|
||||
aria-pressed={mode === "vertical"}
|
||||
aria-label="Mode vertical (planche + texte)"
|
||||
title="Mode vertical (planche + texte)"
|
||||
>
|
||||
<ScrollText size={14} aria-hidden />
|
||||
<span className="hidden sm:inline">Défilement</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "paged" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
onClick={() => setMode("paged")}
|
||||
aria-pressed={mode === "paged"}
|
||||
aria-label="Mode paginé"
|
||||
title="Mode paginé"
|
||||
>
|
||||
<BookOpen size={14} aria-hidden />
|
||||
<span className="hidden sm:inline">Paginé</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn"
|
||||
onClick={() => openLightbox()}
|
||||
disabled={!currentImage}
|
||||
aria-label="Agrandir la planche"
|
||||
title="Agrandir la planche"
|
||||
>
|
||||
<ZoomIn size={14} aria-hidden />
|
||||
</button>
|
||||
{fullscreenDispo && (
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={isFullscreen ? "Quitter le plein écran" : "Activer le plein écran"}
|
||||
title={isFullscreen ? "Quitter le plein écran" : "Plein écran"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 size={14} aria-hidden />
|
||||
) : (
|
||||
<Maximize2 size={14} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone de lecture */}
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[var(--reader-surface)]">
|
||||
{mode === "paged" ? (
|
||||
/* Layout côte à côte ≥ lg : planche fixe à gauche, texte scrollable
|
||||
à droite — les légendes longues ne réduisent plus l'image. */
|
||||
<div className="flex h-full min-h-0 flex-col lg:flex-row">
|
||||
{/* Zone planche */}
|
||||
<div className="relative min-h-[52%] min-w-0 flex-[1.6] lg:h-full lg:min-h-0 lg:flex-[1.15]">
|
||||
<Swiper
|
||||
key="captioned-ltr"
|
||||
initialSlide={current}
|
||||
dir="ltr"
|
||||
modules={[Zoom, Virtual, Mousewheel]}
|
||||
className="h-full w-full"
|
||||
slidesPerView={1}
|
||||
mousewheel={{ forceToAxis: true }}
|
||||
zoom={{ maxRatio: 3, toggle: true }}
|
||||
virtual
|
||||
onSwiper={handleSwiper}
|
||||
onSlideChange={handleSlideChange}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<SwiperSlide
|
||||
// oxlint-disable-next-line react/no-array-index-key -- liste dérivée de `items` dans un ordre stable ; le numéro de planche seul peut se répéter
|
||||
key={`db-page-${item.number}-${i}`}
|
||||
virtualIndex={i}
|
||||
zoom={Boolean(item.imageUrl)}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<div className="swiper-zoom-container flex h-full w-full items-center justify-center bg-[var(--reader-surface)]">
|
||||
{item.imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative flex h-full w-full cursor-zoom-in items-center justify-center border-0 bg-transparent p-0"
|
||||
onClick={() => openLightbox(i)}
|
||||
aria-label={`Agrandir la page ${item.number}`}
|
||||
>
|
||||
<img
|
||||
src={imageSrc(item.imageUrl, 1080)}
|
||||
srcSet={imageSrcSet(item.imageUrl, [828, 1080, 1920]) || undefined}
|
||||
sizes="(min-width: 1024px) 900px, 100vw"
|
||||
alt={`${title} — page ${item.number}`}
|
||||
loading={Math.abs(i - current) <= 1 ? "eager" : "lazy"}
|
||||
draggable={false}
|
||||
className="max-h-full max-w-full object-contain transition-opacity group-hover:opacity-95"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 bottom-3 rounded-md border border-white/15 bg-[var(--reader-surface)]/60 px-2 py-1 text-[18px] font-bold uppercase tracking-wider text-[var(--reader-on-surface)]/70 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Agrandir
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-6 text-center text-[18px] text-[var(--reader-on-surface)]/50">
|
||||
Page {item.number} — pas d'image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Page précédente"
|
||||
onClick={goPrevPage}
|
||||
disabled={current <= 0}
|
||||
className="absolute top-1/2 left-2 z-10 -translate-y-1/2 rounded-full border border-[var(--reader-outline)] bg-[var(--reader-surface)]/60 p-3 text-[var(--reader-on-surface)] transition-colors hover:border-[var(--reader-accent)] hover:text-[var(--reader-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--reader-accent)] disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={20} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Page suivante"
|
||||
onClick={goNextPage}
|
||||
disabled={current >= total - 1}
|
||||
className="absolute top-1/2 right-2 z-10 -translate-y-1/2 rounded-full border border-[var(--reader-outline)] bg-[var(--reader-surface)]/60 p-3 text-[var(--reader-on-surface)] transition-colors hover:border-[var(--reader-accent)] hover:text-[var(--reader-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--reader-accent)] disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={20} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Légende — panneau scrollable indépendant (ne compresse plus l'image). */}
|
||||
<div className="min-h-0 max-h-[38%] shrink-0 border-t border-[var(--reader-outline)] bg-[var(--reader-surface)]/90 px-3 py-2.5 sm:max-h-[48%] sm:px-5 sm:py-4 lg:max-h-none lg:w-[min(420px,38%)] lg:overflow-hidden lg:border-l lg:border-t-0">
|
||||
{legendBlock}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- conteneur défilant : le focus clavier est requis pour le défilement (WCAG 2.1.1)
|
||||
tabIndex={0}
|
||||
// `overscroll-y-auto` et non `contain` : arrivé en bout de lecteur,
|
||||
// la molette doit rendre la main au document. Avec `contain`, le
|
||||
// lecteur avalait le geste et la page restait bloquée — on ne
|
||||
// pouvait plus descendre vers la suite de la fiche sans sortir la
|
||||
// souris de la zone. Le risque d'origine (l'inertie mobile qui
|
||||
// éjectait le lecteur en pleine lecture) est traité autrement : les
|
||||
// planches s'accrochent au défilement, donc le mouvement s'arrête
|
||||
// franchement sur une page au lieu de filer.
|
||||
//
|
||||
// `snap-y snap-proximity` : le « vrai effet de page » — le
|
||||
// défilement s'arrête franchement sur une planche au lieu de
|
||||
// s'immobiliser entre deux images. `proximity` et non `mandatory` :
|
||||
// la liste est virtualisée (éléments montés/démontés en cours de
|
||||
// route, hauteurs mesurées après coup), et une accroche
|
||||
// obligatoire se bat avec ces remesures — elle produit des sauts au
|
||||
// moment où une planche entre dans la fenêtre.
|
||||
className="h-full w-full snap-y snap-proximity overflow-y-auto overflow-x-hidden overscroll-y-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--reader-accent)]"
|
||||
aria-label={`${title} — lecture verticale`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => {
|
||||
const item = items[vi.index];
|
||||
if (!item) return null;
|
||||
return (
|
||||
<div
|
||||
key={`${baseId}-${vi.key}`}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={virtualRowStyle(vi.start)}
|
||||
className="snap-start snap-always border-b border-white/5 px-2 py-4 sm:px-4 sm:py-6"
|
||||
>
|
||||
<div className="group/planche mx-auto flex w-full max-w-3xl flex-col gap-3">
|
||||
<span className="text-[18px] font-bold uppercase tracking-[0.2em] text-[var(--reader-accent)]/70">
|
||||
Page {item.number}
|
||||
</span>
|
||||
{item.imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative cursor-zoom-in border-0 bg-transparent p-0 text-left focus-visible:outline-none"
|
||||
onClick={() => openLightbox(vi.index)}
|
||||
aria-label={`Ouvrir la page ${item.number} en plein écran`}
|
||||
>
|
||||
<img
|
||||
src={imageSrc(item.imageUrl, 1080)}
|
||||
srcSet={imageSrcSet(item.imageUrl, [828, 1080, 1920]) || undefined}
|
||||
sizes="(min-width: 768px) 768px, 100vw"
|
||||
alt={`${title} — page ${item.number}`}
|
||||
// Trois planches de part et d'autre chargées d'avance :
|
||||
// avec une seule, tourner la page laissait un rectangle
|
||||
// vide le temps du téléchargement d'un scan de 1 à 2 Mo.
|
||||
loading={Math.abs(vi.index - current) <= 3 ? "eager" : "lazy"}
|
||||
// `high` sur la planche courante : le navigateur la sert
|
||||
// avant les voisines préchargées.
|
||||
fetchPriority={vi.index === current ? "high" : "auto"}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="h-auto w-full rounded-sm bg-[var(--reader-surface)] object-contain shadow-[0_0_24px_rgba(0,0,0,0.45)] transition-[transform,box-shadow] duration-300 ease-out will-change-transform group-hover:scale-[1.015] group-hover:shadow-[0_0_60px_rgba(0,0,0,0.75)] group-focus-visible:scale-[1.015] motion-reduce:transform-none motion-reduce:transition-none"
|
||||
/>
|
||||
{/* Appel au plein écran, révélé au survol de la planche. */}
|
||||
<span className="pointer-events-none absolute right-3 bottom-3 inline-flex items-center gap-1.5 rounded-md border border-white/15 bg-[var(--reader-surface)]/70 px-2.5 py-1.5 text-[18px] font-bold uppercase tracking-wider text-[var(--reader-on-surface)]/80 opacity-0 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<ZoomIn size={12} aria-hidden />
|
||||
Plein écran
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-h-[120px] items-center justify-center rounded border border-dashed border-white/10 bg-[var(--reader-surface)]/40 text-[18px] text-[var(--reader-on-surface)]/50">
|
||||
Pas d'image pour cette page
|
||||
</div>
|
||||
)}
|
||||
{texteDe(item) ? (
|
||||
<div className="rounded-md border border-[var(--reader-outline)]/40 bg-[var(--reader-surface)]/50 px-4 py-3">
|
||||
{renderTranscription?.({
|
||||
text: texteDe(item) as string,
|
||||
page: item.number,
|
||||
verified: item.verifiee,
|
||||
}) ?? texteDe(item)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bande de vignettes (recueils courts). */}
|
||||
{mode === "paged" && total > 0 && total <= SHOW_STRIP_MAX && (
|
||||
<div className="z-10 flex items-center gap-4 overflow-x-auto border-t border-[var(--reader-outline)] bg-[var(--reader-surface)]/90 px-4 py-3 scrollbar-thin">
|
||||
<div className="mx-auto flex gap-2">
|
||||
{items.map((item, idx) => (
|
||||
<button
|
||||
// oxlint-disable-next-line react/no-array-index-key -- liste dérivée de `items` dans un ordre stable ; le numéro de planche seul peut se répéter
|
||||
key={`thumb-${item.number}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => goTo(idx)}
|
||||
className={`relative h-16 w-12 flex-shrink-0 overflow-hidden rounded border-2 transition-all ${
|
||||
current === idx
|
||||
? "scale-105 border-[var(--reader-accent)] shadow-[0_0_10px_rgba(255,178,0,0.4)]"
|
||||
: "border-white/10 hover:border-white/30"
|
||||
}`}
|
||||
title={`Page ${item.number}`}
|
||||
aria-label={`Aller à la page ${item.number}`}
|
||||
aria-current={current === idx ? "true" : undefined}
|
||||
>
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
// Vignette de 48×64 px : elle chargeait le scan source (jusqu'à
|
||||
// 5 Mio) pour un timbre-poste, jusqu'à 40 fois par fiche.
|
||||
src={imageSrc(item.imageUrl, 96)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center bg-zinc-900 text-[18px] text-[var(--reader-on-surface)]/50">
|
||||
txt
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute inset-x-0 bottom-0 bg-[var(--reader-surface)]/70 text-center font-mono text-[18px] font-bold text-[var(--reader-on-surface)]">
|
||||
{item.number}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saut direct pour les longs recueils. */}
|
||||
{mode === "paged" && total > SHOW_STRIP_MAX && (
|
||||
<form
|
||||
className="z-10 flex items-center justify-center gap-3 border-t-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-4 py-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const field = e.currentTarget.elements.namedItem("goto") as HTMLInputElement | null;
|
||||
const wanted = Number(field?.value);
|
||||
if (!Number.isFinite(wanted)) return;
|
||||
// Cherche d'abord par numéro éditorial, sinon par position 1-based.
|
||||
const byNumber = items.findIndex((it) => it.number === Math.trunc(wanted));
|
||||
if (byNumber >= 0) goTo(byNumber);
|
||||
else goTo(Math.trunc(wanted) - 1);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor={`${baseId}-goto`}
|
||||
className="text-[18px] font-black uppercase tracking-wider text-[var(--reader-on-surface)]"
|
||||
>
|
||||
Aller à n°
|
||||
</label>
|
||||
<input
|
||||
id={`${baseId}-goto`}
|
||||
name="goto"
|
||||
type="number"
|
||||
key={currentNum}
|
||||
defaultValue={currentNum}
|
||||
className="w-20 rounded-md border-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-2.5 py-1 text-center text-[18px] font-extrabold tabular-nums text-[var(--reader-accent)] shadow-[2px_2px_0px_#000] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--reader-accent)]"
|
||||
/>
|
||||
<button type="submit" className="m3-reader-btn-active !py-1 !px-3.5">
|
||||
OK
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Lightbox agrandissement planche */}
|
||||
{lightbox && currentImage && (
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- clic sur le fond = fermeture ; la touche Échap est gérée par useFocusTrap (onEscape)
|
||||
<div
|
||||
ref={lightboxRef}
|
||||
// `tabIndex={-1}` : requis par `useFocusTrap`, qui donne le focus au
|
||||
// panneau à l'ouverture. Sans piège, la tabulation continuait DERRIÈRE
|
||||
// la visionneuse plein écran — le focus disparaissait pour l'utilisateur
|
||||
// clavier et pour le lecteur d'écran.
|
||||
tabIndex={-1}
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- dialogue modal géré par useFocusTrap (aria-modal, piège de focus) ; <dialog> natif imposerait showModal()
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${title} — page ${currentNum} agrandie`}
|
||||
className="fixed inset-0 z-[100] flex flex-col bg-[var(--reader-surface)]/95"
|
||||
onClick={() => setLightbox(false)}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-4 py-3">
|
||||
<span className="min-w-0 flex-1 truncate font-sans text-[18px] font-extrabold uppercase tracking-wide text-[var(--reader-on-surface)]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black">
|
||||
<span>n°{currentNum}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLightbox(false);
|
||||
}}
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- simple arrêt de propagation : empêche la fermeture par clic sur le fond, sans action clavier propre */}
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 items-center justify-center p-2 sm:p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<img
|
||||
src={currentImage}
|
||||
alt={`${title} — page ${currentNum}`}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Page précédente"
|
||||
onClick={() => goTo(current - 1)}
|
||||
disabled={current <= 0}
|
||||
className="absolute top-1/2 left-4 -translate-y-1/2 rounded-lg border-2 border-[var(--reader-outline)] bg-[var(--reader-surface)]/90 p-3 text-[var(--reader-accent)] shadow-[3px_3px_0px_#000] hover:bg-[var(--reader-accent)] hover:text-[var(--reader-on-accent)] transition-all disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={22} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Page suivante"
|
||||
onClick={() => goTo(current + 1)}
|
||||
disabled={current >= total - 1}
|
||||
className="absolute top-1/2 right-4 -translate-y-1/2 rounded-lg border-2 border-[var(--reader-outline)] bg-[var(--reader-surface)]/90 p-3 text-[var(--reader-accent)] shadow-[3px_3px_0px_#000] hover:bg-[var(--reader-accent)] hover:text-[var(--reader-on-accent)] transition-all disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={22} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
|
||||
|
||||
export interface ReaderCollectionChapter {
|
||||
id: string | number;
|
||||
label: string;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface ReaderCollectionEdition {
|
||||
id: string;
|
||||
label: string;
|
||||
chapters: ReaderCollectionChapter[];
|
||||
}
|
||||
|
||||
export interface CollectionReaderProps {
|
||||
title: string;
|
||||
editions: ReaderCollectionEdition[];
|
||||
initialEdition?: string;
|
||||
initialChapter?: string | number;
|
||||
emptyLabel: string;
|
||||
contentsLabel: string;
|
||||
previousLabel: string;
|
||||
nextLabel: string;
|
||||
onSelectionChange?: (edition: ReaderCollectionEdition, chapter: ReaderCollectionChapter) => void;
|
||||
renderChapter: (
|
||||
edition: ReaderCollectionEdition,
|
||||
chapter: ReaderCollectionChapter,
|
||||
index: number,
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
/** Edition and chapter navigation shared by manga volumes and illustrated collections. */
|
||||
export function CollectionReader({
|
||||
title,
|
||||
editions,
|
||||
initialEdition,
|
||||
initialChapter,
|
||||
emptyLabel,
|
||||
contentsLabel,
|
||||
previousLabel,
|
||||
nextLabel,
|
||||
onSelectionChange,
|
||||
renderChapter,
|
||||
}: CollectionReaderProps) {
|
||||
const contentsId = useId();
|
||||
const available = useMemo(
|
||||
() =>
|
||||
editions
|
||||
.map((edition) => ({
|
||||
...edition,
|
||||
chapters: edition.chapters.filter((chapter) => chapter.pageCount > 0),
|
||||
}))
|
||||
.filter((edition) => edition.chapters.length > 0),
|
||||
[editions],
|
||||
);
|
||||
const [editionId, setEditionId] = useState(
|
||||
() => available.find((edition) => edition.id === initialEdition)?.id ?? available[0]?.id ?? "",
|
||||
);
|
||||
const edition = available.find((item) => item.id === editionId) ?? available[0];
|
||||
const [chapterIndex, setChapterIndex] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
edition?.chapters.findIndex((chapter) => String(chapter.id) === String(initialChapter)) ?? 0,
|
||||
),
|
||||
);
|
||||
const [contentsOpen, setContentsOpen] = useState(false);
|
||||
const safeIndex = Math.min(chapterIndex, Math.max(0, (edition?.chapters.length ?? 0) - 1));
|
||||
const chapter = edition?.chapters[safeIndex];
|
||||
|
||||
useEffect(() => {
|
||||
if (edition && chapter) onSelectionChange?.(edition, chapter);
|
||||
}, [edition, chapter, onSelectionChange]);
|
||||
|
||||
if (!edition || !chapter) {
|
||||
return (
|
||||
<p
|
||||
className="m3-reader-collection-empty"
|
||||
data-m3-component="CollectionReader"
|
||||
data-testid="collection-reader-empty"
|
||||
>
|
||||
{emptyLabel}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="m3-reader m3-reader-collection"
|
||||
aria-label={title}
|
||||
data-m3-component="CollectionReader"
|
||||
data-testid="collection-reader"
|
||||
>
|
||||
<div className="m3-reader-collection-toolbar">
|
||||
<div
|
||||
className="m3-reader-collection-editions"
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- groupe de boutons : une fieldset imposerait bordure et marges par défaut au style existant
|
||||
role="group"
|
||||
aria-label={title}
|
||||
>
|
||||
{available.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={item.id === edition.id ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
aria-pressed={item.id === edition.id}
|
||||
onClick={() => {
|
||||
setEditionId(item.id);
|
||||
setChapterIndex(0);
|
||||
setContentsOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn"
|
||||
aria-expanded={contentsOpen}
|
||||
aria-controls={contentsOpen ? contentsId : undefined}
|
||||
onClick={() => setContentsOpen((value) => !value)}
|
||||
>
|
||||
{contentsLabel} ({edition.chapters.length})
|
||||
</button>
|
||||
</div>
|
||||
{contentsOpen && (
|
||||
<div
|
||||
id={contentsId}
|
||||
className="m3-reader-collection-contents"
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- groupe de boutons : une fieldset imposerait bordure et marges par défaut au style existant
|
||||
role="group"
|
||||
aria-label={contentsLabel}
|
||||
>
|
||||
{edition.chapters.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={index === safeIndex ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
aria-current={index === safeIndex ? "page" : undefined}
|
||||
disabled={item.pageCount === 0}
|
||||
onClick={() => {
|
||||
setChapterIndex(index);
|
||||
setContentsOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.label} · {item.pageCount}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderChapter(edition, chapter, safeIndex)}
|
||||
<nav className="m3-reader-collection-navigation" aria-label={contentsLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn"
|
||||
disabled={safeIndex === 0}
|
||||
onClick={() => setChapterIndex(safeIndex - 1)}
|
||||
>
|
||||
{previousLabel}
|
||||
</button>
|
||||
<span aria-live="polite">
|
||||
{chapter.label} ({safeIndex + 1}/{edition.chapters.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn"
|
||||
disabled={safeIndex >= edition.chapters.length - 1}
|
||||
onClick={() => setChapterIndex(safeIndex + 1)}
|
||||
>
|
||||
{nextLabel}
|
||||
</button>
|
||||
</nav>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Lecteur de scan manga complet, 100 % client.
|
||||
*
|
||||
* Deux modes togglables :
|
||||
* - Paginé : Swiper (Keyboard, Zoom, Virtual, Mousewheel), RTL réel,
|
||||
* double-page optionnelle sur grand écran.
|
||||
* - Vertical : défilement webtoon virtualisé via `@tanstack/react-virtual`
|
||||
* (mesure dynamique des planches, overscan raisonnable).
|
||||
*
|
||||
* URL resolution and chapter persistence are supplied by the host.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoveHorizontal,
|
||||
ScrollText,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Keyboard, Mousewheel, Zoom } from "swiper/modules";
|
||||
|
||||
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
||||
import "swiper/css";
|
||||
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
||||
import "swiper/css/zoom";
|
||||
|
||||
import {
|
||||
SHOW_STRIP_MAX,
|
||||
type ViewMode,
|
||||
useFullscreen,
|
||||
useSwiperSync,
|
||||
useVerticalScrollSync,
|
||||
virtualRowStyle,
|
||||
} from "./use-reader-core";
|
||||
|
||||
export type MangaReaderProps = {
|
||||
pages: string[];
|
||||
title: string;
|
||||
direction?: "rtl" | "ltr";
|
||||
prevHref?: string | null;
|
||||
nextHref?: string | null;
|
||||
prevPages?: string[] | null;
|
||||
nextPages?: string[] | null;
|
||||
initialPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
apiPageHref?: (page: number) => string | null;
|
||||
resolveAssetUrl?: (path: string) => string;
|
||||
resolveMirrorUrl?: (url: string) => string | null;
|
||||
renderChapterLink?: (props: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
className: string;
|
||||
ariaLabel: string;
|
||||
}) => ReactNode;
|
||||
};
|
||||
|
||||
const LG_BREAKPOINT = 1024; // Tailwind `lg`
|
||||
|
||||
const identityAssetUrl = (path: string) => path;
|
||||
|
||||
/** Détecte le passage sous/au-delà du breakpoint `lg` pour la double-page. */
|
||||
function useIsLargeScreen(): boolean {
|
||||
const [isLarge, setIsLarge] = useState(false);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
|
||||
const sync = () => setIsLarge(mql.matches);
|
||||
sync();
|
||||
mql.addEventListener("change", sync);
|
||||
return () => mql.removeEventListener("change", sync);
|
||||
}, []);
|
||||
return isLarge;
|
||||
}
|
||||
|
||||
export function MangaReader({
|
||||
pages,
|
||||
title,
|
||||
direction = "rtl",
|
||||
prevHref = null,
|
||||
nextHref = null,
|
||||
prevPages = null,
|
||||
nextPages = null,
|
||||
initialPage = 1,
|
||||
onPageChange,
|
||||
apiPageHref,
|
||||
resolveAssetUrl = identityAssetUrl,
|
||||
resolveMirrorUrl,
|
||||
renderChapterLink,
|
||||
}: MangaReaderProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [mode, setMode] = useState<ViewMode>("paged");
|
||||
const [dir, setDir] = useState<"rtl" | "ltr">(direction);
|
||||
const [spread, setSpread] = useState(false);
|
||||
const [current, setCurrent] = useState(() =>
|
||||
Math.max(0, Math.min(pages.length - 1, initialPage - 1)),
|
||||
);
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
|
||||
const { swiperRef, handleSwiper, handleSlideChange } = useSwiperSync(setCurrent);
|
||||
|
||||
const isLarge = useIsLargeScreen();
|
||||
// Double-page seulement effective sur grand écran (lisibilité mobile).
|
||||
const useSpread = spread && isLarge && mode === "paged";
|
||||
|
||||
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Défilement automatique fluide de la bande de vignettes vers la planche active
|
||||
useEffect(() => {
|
||||
if (mode === "paged" && activeThumbRef.current) {
|
||||
activeThumbRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
}, [current, mode]);
|
||||
|
||||
// Résolution des URL absolues une seule fois (les chemins sont stables).
|
||||
const urls = useMemo(() => pages.map(resolveAssetUrl), [pages, resolveAssetUrl]);
|
||||
const total = urls.length;
|
||||
const baseId = useId();
|
||||
|
||||
// Préchargement intelligent des planches en arrière-plan
|
||||
useEffect(() => {
|
||||
if (urls.length === 0) return;
|
||||
// 1. Précharge la page courante, la précédente (retour arrière) et les 3 suivantes
|
||||
const indices = [current, current - 1, current + 1, current + 2, current + 3];
|
||||
for (const idx of indices) {
|
||||
if (idx >= 0 && idx < urls.length) {
|
||||
const img = new Image();
|
||||
img.src = urls[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Précharge les 3 premières pages du chapitre suivant en arrière-plan
|
||||
if (nextPages && nextPages.length > 0) {
|
||||
nextPages.slice(0, 3).forEach((page) => {
|
||||
const img = new Image();
|
||||
img.src = resolveAssetUrl(page);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Précharge les 3 dernières pages du chapitre précédent en arrière-plan (si l'utilisateur veut revenir en arrière)
|
||||
if (prevPages && prevPages.length > 0) {
|
||||
prevPages.slice(-3).forEach((page) => {
|
||||
const img = new Image();
|
||||
img.src = resolveAssetUrl(page);
|
||||
});
|
||||
}
|
||||
}, [current, urls, prevPages, nextPages, resolveAssetUrl]);
|
||||
|
||||
// Repli automatique CDN <-> Bot en cas d'erreur réseau sur une planche
|
||||
const handleImageError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallbackApplied) return;
|
||||
target.dataset.fallbackApplied = "true";
|
||||
try {
|
||||
const mirror = resolveMirrorUrl?.(target.src);
|
||||
if (mirror) target.src = mirror;
|
||||
} catch {
|
||||
// URL invalide, aucun repli possible
|
||||
}
|
||||
},
|
||||
[resolveMirrorUrl],
|
||||
);
|
||||
|
||||
/* ------------------------------ Vertical ------------------------------ */
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null);
|
||||
// oxlint-disable-next-line react/incompatible-library -- TanStack Virtual renvoie des fonctions non mémoïsables (getVirtualItems, measureElement) : le compilateur React doit l'ignorer
|
||||
const virtualizer = useVirtualizer({
|
||||
count: total,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
// Estimation ~ hauteur viewport ; remplacée par la mesure réelle.
|
||||
estimateSize: () => (typeof window === "undefined" ? 900 : window.innerHeight),
|
||||
overscan: 3,
|
||||
});
|
||||
|
||||
// Synchronise le compteur en mode vertical sur la page la plus visible.
|
||||
useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent);
|
||||
|
||||
useEffect(() => {
|
||||
onPageChange?.(current + 1);
|
||||
}, [current, onPageChange]);
|
||||
const apiPlanche = apiPageHref?.(current + 1) ?? null;
|
||||
|
||||
/* ------------------------------ Contrôles ----------------------------- */
|
||||
|
||||
const goPrevPage = useCallback(() => {
|
||||
if (mode === "paged") swiperRef.current?.slidePrev();
|
||||
else virtualizer.scrollToIndex(Math.max(0, current - 1), { align: "start" });
|
||||
}, [mode, current, swiperRef, virtualizer]);
|
||||
|
||||
const goNextPage = useCallback(() => {
|
||||
if (mode === "paged") swiperRef.current?.slideNext();
|
||||
else
|
||||
virtualizer.scrollToIndex(Math.min(total - 1, current + 1), {
|
||||
align: "start",
|
||||
});
|
||||
}, [mode, current, total, swiperRef, virtualizer]);
|
||||
|
||||
const counter = total ? `${Math.min(current + 1, total)} / ${total}` : "0 / 0";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="m3-reader m3-reader-container m3-reader-scroll flex h-[85vh] min-h-[460px] w-full min-w-0 max-w-full flex-col overflow-hidden rounded-xl bg-[var(--reader-surface)] [&:fullscreen]:h-screen [&:fullscreen]:min-h-screen [&:fullscreen]:rounded-none"
|
||||
data-manga-reader
|
||||
data-m3-component="MangaReader"
|
||||
data-testid="manga-reader"
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- le markup SSR doit garder role="region" (contrat crawlable-ssr) ; un <section> sans role changerait la sortie rendue
|
||||
role="region"
|
||||
aria-label={title}
|
||||
>
|
||||
{/* Barre de contrôles (HUD) */}
|
||||
<div className="m3-reader-hud z-10 flex flex-wrap items-center justify-between gap-2 px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="min-w-0 max-w-[32ch] md:max-w-[45ch] truncate font-sans text-[18px] font-extrabold uppercase tracking-wide text-[var(--reader-on-surface)] drop-shadow-[1px_1px_0px_#000]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black">
|
||||
<span>{counter}</span>
|
||||
</span>
|
||||
{/* L'adresse de LA planche affichée, en un clic : la même que celle
|
||||
que rendra `curl`. C'est le seul endroit où la correspondance
|
||||
entre ce qu'on voit et ce qu'on peut récupérer est évidente. */}
|
||||
{apiPlanche && (
|
||||
<a
|
||||
href={apiPlanche}
|
||||
className="hidden shrink-0 rounded border border-white/15 px-1.5 py-0.5 font-mono text-[18px] font-bold uppercase tracking-wider text-[var(--reader-on-surface)]/50 transition-colors hover:border-[var(--reader-accent)] hover:text-[var(--reader-accent)] sm:inline-block"
|
||||
title="Cette planche en JSON (image + transcription)"
|
||||
>
|
||||
API
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 ml-auto">
|
||||
{/* Toggle mode lecture */}
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "paged" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
onClick={() => setMode("paged")}
|
||||
aria-pressed={mode === "paged"}
|
||||
aria-label="Mode de lecture paginé"
|
||||
title="Mode paginé"
|
||||
>
|
||||
<BookOpen size={13} aria-hidden /> Paginé
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "vertical" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
onClick={() => setMode("vertical")}
|
||||
aria-pressed={mode === "vertical"}
|
||||
aria-label="Mode de lecture vertical (webtoon)"
|
||||
title="Mode vertical (webtoon)"
|
||||
>
|
||||
<ScrollText size={13} aria-hidden /> Défilement
|
||||
</button>
|
||||
|
||||
{/* Toggle sens de lecture */}
|
||||
<button
|
||||
type="button"
|
||||
className={dir === "rtl" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
onClick={() => setDir((d) => (d === "rtl" ? "ltr" : "rtl"))}
|
||||
aria-label={
|
||||
dir === "rtl"
|
||||
? "Sens de lecture manga (droite vers gauche)"
|
||||
: "Sens de lecture BD (gauche vers droite)"
|
||||
}
|
||||
title={dir === "rtl" ? "Sens manga (droite→gauche)" : "Sens BD (gauche→droite)"}
|
||||
>
|
||||
<MoveHorizontal size={13} aria-hidden />
|
||||
{dir === "rtl" ? "RTL" : "LTR"}
|
||||
</button>
|
||||
|
||||
{/* Toggle double-page (paginé + grand écran) */}
|
||||
<button
|
||||
type="button"
|
||||
className={useSpread ? "m3-reader-btn-active" : "m3-reader-btn"}
|
||||
onClick={() => setSpread((s) => !s)}
|
||||
disabled={mode !== "paged" || !isLarge}
|
||||
aria-pressed={useSpread}
|
||||
aria-label="Affichage double-page (grand écran)"
|
||||
title="Double-page (grand écran)"
|
||||
>
|
||||
{useSpread ? <Columns2 size={13} aria-hidden /> : <Square size={13} aria-hidden />}
|
||||
Double
|
||||
</button>
|
||||
|
||||
{/* Plein écran */}
|
||||
<button
|
||||
type="button"
|
||||
className="m3-reader-btn"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={isFullscreen ? "Quitter le plein écran" : "Activer le plein écran"}
|
||||
title={isFullscreen ? "Quitter le plein écran" : "Plein écran"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 size={13} aria-hidden />
|
||||
) : (
|
||||
<Maximize2 size={13} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Navigation chapitres */}
|
||||
{prevHref ? (
|
||||
renderChapterLink ? (
|
||||
renderChapterLink({
|
||||
href: prevHref,
|
||||
children: (
|
||||
<>
|
||||
<ChevronLeft size={13} aria-hidden /> Préc.
|
||||
</>
|
||||
),
|
||||
className: "m3-reader-btn",
|
||||
ariaLabel: "Chapitre précédent",
|
||||
})
|
||||
) : (
|
||||
<a
|
||||
href={prevHref}
|
||||
className="m3-reader-btn"
|
||||
aria-label="Chapitre précédent"
|
||||
title="Chapitre précédent"
|
||||
>
|
||||
<ChevronLeft size={13} aria-hidden /> Préc.
|
||||
</a>
|
||||
)
|
||||
) : (
|
||||
<span className="m3-reader-btn pointer-events-none opacity-40">
|
||||
<ChevronLeft size={13} aria-hidden /> Préc.
|
||||
</span>
|
||||
)}
|
||||
{nextHref ? (
|
||||
renderChapterLink ? (
|
||||
renderChapterLink({
|
||||
href: nextHref,
|
||||
children: (
|
||||
<>
|
||||
Suiv. <ChevronRight size={13} aria-hidden />
|
||||
</>
|
||||
),
|
||||
className: "m3-reader-btn",
|
||||
ariaLabel: "Chapitre suivant",
|
||||
})
|
||||
) : (
|
||||
<a
|
||||
href={nextHref}
|
||||
className="m3-reader-btn"
|
||||
aria-label="Chapitre suivant"
|
||||
title="Chapitre suivant"
|
||||
>
|
||||
Suiv. <ChevronRight size={13} aria-hidden />
|
||||
</a>
|
||||
)
|
||||
) : (
|
||||
<span className="m3-reader-btn pointer-events-none opacity-40">
|
||||
Suiv. <ChevronRight size={13} aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone de lecture */}
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[var(--reader-surface)]">
|
||||
{total === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-[18px] text-[var(--reader-on-surface)]">
|
||||
Aucune planche disponible.
|
||||
</div>
|
||||
) : mode === "paged" ? (
|
||||
<>
|
||||
<Swiper
|
||||
key={`${dir}-${useSpread ? "spread" : "single"}`}
|
||||
// Le `key` force un remount à chaque changement de sens/double-page :
|
||||
// on repart de la planche courante plutôt que de la page 1.
|
||||
initialSlide={current}
|
||||
dir={dir}
|
||||
modules={[Keyboard, Zoom, Mousewheel]}
|
||||
className="h-full w-full max-w-full"
|
||||
slidesPerView={useSpread ? 2 : 1}
|
||||
spaceBetween={useSpread ? 8 : 0}
|
||||
keyboard={{ enabled: true }}
|
||||
mousewheel={{ forceToAxis: true }}
|
||||
zoom={{ maxRatio: 3, toggle: true }}
|
||||
onSwiper={handleSwiper}
|
||||
onSlideChange={handleSlideChange}
|
||||
>
|
||||
{urls.map((url, i) => (
|
||||
<SwiperSlide key={url} zoom className="flex items-center justify-center">
|
||||
{/* `swiper-zoom-container` requis par le module Zoom (pan/pinch). */}
|
||||
<div className="swiper-zoom-container flex h-full w-full max-w-full items-center justify-center">
|
||||
<img
|
||||
src={url}
|
||||
alt={`${title} — page ${i + 1}`}
|
||||
loading={Math.abs(i - current) <= 1 ? "eager" : "lazy"}
|
||||
draggable={false}
|
||||
className="max-h-full max-w-full object-contain pointer-events-none select-none"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
{/* Flèches de navigation (respectent le sens RTL/LTR). */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Page précédente"
|
||||
onClick={goPrevPage}
|
||||
className="absolute top-1/2 left-3 z-10 -translate-y-1/2 rounded-lg border-2 border-[var(--reader-outline)] bg-[var(--reader-surface)]/85 p-2.5 text-[var(--reader-accent)] shadow-[3px_3px_0px_#000] transition-all hover:bg-[var(--reader-accent)] hover:text-[var(--reader-on-accent)] hover:scale-105 active:translate-y-[calc(-50%+1px)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--reader-accent)]"
|
||||
>
|
||||
{dir === "rtl" ? (
|
||||
<ChevronRight size={22} aria-hidden />
|
||||
) : (
|
||||
<ChevronLeft size={22} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Page suivante"
|
||||
onClick={goNextPage}
|
||||
className="absolute top-1/2 right-3 z-10 -translate-y-1/2 rounded-lg border-2 border-[var(--reader-outline)] bg-[var(--reader-surface)]/85 p-2.5 text-[var(--reader-accent)] shadow-[3px_3px_0px_#000] transition-all hover:bg-[var(--reader-accent)] hover:text-[var(--reader-on-accent)] hover:scale-105 active:translate-y-[calc(-50%+1px)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--reader-accent)]"
|
||||
>
|
||||
{dir === "rtl" ? (
|
||||
<ChevronLeft size={22} aria-hidden />
|
||||
) : (
|
||||
<ChevronRight size={22} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
// `tabIndex` requis pour rendre le conteneur défilable au clavier
|
||||
// (flèches/PgUp/PgDn) — sinon la lecture verticale est inatteignable.
|
||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- zone de lecture défilable au clavier : aucun rôle interactif ne la décrit, retirer tabIndex rendrait le mode vertical inatteignable au clavier
|
||||
tabIndex={0}
|
||||
className="h-full w-full overflow-y-auto overflow-x-hidden m3-reader-scroll focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--reader-accent)]"
|
||||
aria-label={`${title} — lecture verticale`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
key={`${baseId}-${vi.key}`}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={virtualRowStyle(vi.start)}
|
||||
className="flex justify-center py-2"
|
||||
>
|
||||
<img
|
||||
src={urls[vi.index]}
|
||||
alt={`${title} — page ${vi.index + 1}`}
|
||||
loading={Math.abs(vi.index - current) <= 1 ? "eager" : "lazy"}
|
||||
draggable={false}
|
||||
className="h-auto w-full max-w-3xl object-contain border-y md:border-2 border-[var(--reader-outline)]/40 shadow-[0_4px_12px_rgba(0,0,0,0.8)]"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bande de vignettes (chapitres courts uniquement — cf. SHOW_STRIP_MAX). */}
|
||||
{mode === "paged" && total > 0 && total <= SHOW_STRIP_MAX && (
|
||||
<div className="z-10 w-full min-w-0 max-w-full bg-[var(--reader-surface)] border-t-2 border-[var(--reader-outline)] px-4 py-3 flex items-center overflow-x-auto m3-reader-scroll">
|
||||
<div className="flex gap-2.5 min-w-max mx-auto px-2">
|
||||
{urls.map((url, idx) => (
|
||||
<button
|
||||
key={url}
|
||||
ref={current === idx ? activeThumbRef : undefined}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrent(idx);
|
||||
swiperRef.current?.slideTo(idx);
|
||||
}}
|
||||
className={`relative shrink-0 w-12 h-16 rounded-md overflow-hidden border-2 transition-all ${
|
||||
current === idx
|
||||
? "border-[var(--reader-accent)] scale-105 shadow-[0_0_12px_var(--reader-accent)]"
|
||||
: "border-[var(--reader-outline)] shadow-[2px_2px_0px_#000] hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Page ${idx + 1}`}
|
||||
className="w-full h-full object-cover select-none pointer-events-none"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute bottom-0 inset-x-0 bg-[var(--reader-surface)]/85 text-[18px] font-extrabold text-[var(--reader-on-surface)] text-center font-mono py-0.5 border-t border-[var(--reader-outline)]/60">
|
||||
{idx + 1}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tomes longs : la bande de vignettes serait trop lourde (jusqu'à ~282 nœuds)
|
||||
→ saut direct à une planche par saisie du numéro. */}
|
||||
{mode === "paged" && total > SHOW_STRIP_MAX && (
|
||||
<form
|
||||
className="z-10 flex items-center justify-center gap-3 border-t-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-4 py-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const field = e.currentTarget.elements.namedItem("goto") as HTMLInputElement | null;
|
||||
const val = Number(field?.value);
|
||||
if (!Number.isFinite(val)) return;
|
||||
const i = Math.min(Math.max(val - 1, 0), total - 1);
|
||||
setCurrent(i);
|
||||
swiperRef.current?.slideTo(i);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor={`${baseId}-goto`}
|
||||
className="text-[18px] font-black uppercase tracking-wider text-[var(--reader-on-surface)]"
|
||||
>
|
||||
Aller à la planche
|
||||
</label>
|
||||
<input
|
||||
id={`${baseId}-goto`}
|
||||
name="goto"
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
key={current}
|
||||
defaultValue={current + 1}
|
||||
className="w-20 rounded-md border-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-2.5 py-1 text-center text-[18px] font-extrabold tabular-nums text-[var(--reader-accent)] shadow-[2px_2px_0px_#000] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--reader-accent)]"
|
||||
/>
|
||||
<span className="text-[18px] font-bold tabular-nums text-[var(--reader-on-surface)]/70">
|
||||
/ {total}
|
||||
</span>
|
||||
<button type="submit" className="m3-reader-btn-active !py-1 !px-3.5">
|
||||
OK
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
export { MangaReader } from "./MangaReader";
|
||||
export type { MangaReaderProps } from "./MangaReader";
|
||||
export { CaptionedReader } from "./CaptionedReader";
|
||||
export type {
|
||||
CaptionedReaderPage,
|
||||
CaptionedReaderProps,
|
||||
ReaderTranscription,
|
||||
} from "./CaptionedReader";
|
||||
export { CollectionReader } from "./CollectionReader";
|
||||
export type {
|
||||
CollectionReaderProps,
|
||||
ReaderCollectionChapter,
|
||||
ReaderCollectionEdition,
|
||||
} from "./CollectionReader";
|
||||
@@ -0,0 +1,430 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
|
||||
import { act, useRef, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useFullscreen } from "./use-reader-core";
|
||||
import { useFocusTrap } from "./use-focus-trap";
|
||||
|
||||
const environment = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalOwner = Object.getOwnPropertyDescriptor(document, "fullscreenElement");
|
||||
const originalExit = Object.getOwnPropertyDescriptor(document, "exitFullscreen");
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
const mounts: { root: Root; host: HTMLElement; active: boolean }[] = [];
|
||||
const extraElements: HTMLElement[] = [];
|
||||
let owner: Element | null = null;
|
||||
|
||||
async function mount(node: ReactNode) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const mounted = { root: createRoot(host), host, active: true };
|
||||
mounts.push(mounted);
|
||||
await act(() => mounted.root.render(node));
|
||||
return mounted;
|
||||
}
|
||||
|
||||
async function unmount(mounted: (typeof mounts)[number]) {
|
||||
await act(() => mounted.root.unmount());
|
||||
mounted.active = false;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// Each React root must finish its effect cleanup before the next root is removed.
|
||||
for (const mounted of mounts.splice(0)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (mounted.active) await unmount(mounted);
|
||||
mounted.host.remove();
|
||||
}
|
||||
for (const element of extraElements.splice(0)) element.remove();
|
||||
if (originalOwner) Object.defineProperty(document, "fullscreenElement", originalOwner);
|
||||
else Reflect.deleteProperty(document, "fullscreenElement");
|
||||
if (originalExit) Object.defineProperty(document, "exitFullscreen", originalExit);
|
||||
else Reflect.deleteProperty(document, "exitFullscreen");
|
||||
document.body.style.overflow = originalOverflow;
|
||||
owner = null;
|
||||
});
|
||||
afterAll(() => {
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
});
|
||||
|
||||
function platform() {
|
||||
// Happy DOM has no native fullscreen. Simulate only this platform boundary;
|
||||
// the hook, React roots, ownership, DOM events and effect cleanup remain real.
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => owner });
|
||||
const exit = mock(async () => {
|
||||
owner = null;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
});
|
||||
Object.defineProperty(document, "exitFullscreen", { configurable: true, value: exit });
|
||||
return exit;
|
||||
}
|
||||
|
||||
async function fullscreenReader(id = "reader", present = true) {
|
||||
// Observation sink written by the rendered component: the test reads the hook's latest result.
|
||||
const observed: { latest?: ReturnType<typeof useFullscreen>; renders: number } = { renders: 0 };
|
||||
function Reader() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// oxlint-disable-next-line react/immutability -- harness de test : le composant publie le dernier résultat du hook pour les assertions
|
||||
observed.latest = useFullscreen(ref);
|
||||
// oxlint-disable-next-line react/immutability -- harness de test : compte des rendus observé par le test (fuite de cycle de vie)
|
||||
observed.renders++;
|
||||
return present ? <div ref={ref} data-reader={id} /> : null;
|
||||
}
|
||||
const mounted = await mount(<Reader />);
|
||||
const element = mounted.host.querySelector<HTMLDivElement>("[data-reader]");
|
||||
const request = mock(async () => {
|
||||
owner = element;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
});
|
||||
if (element)
|
||||
Object.defineProperty(element, "requestFullscreen", { configurable: true, value: request });
|
||||
return {
|
||||
mounted,
|
||||
element,
|
||||
request,
|
||||
get latest() {
|
||||
return observed.latest!;
|
||||
},
|
||||
get renders() {
|
||||
return observed.renders;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("reader fullscreen ownership and lifetime", () => {
|
||||
test("another element's fullscreen is neither reported nor exited", async () => {
|
||||
const exit = platform();
|
||||
owner = document.createElement("video");
|
||||
const reader = await fullscreenReader();
|
||||
expect(reader.latest.isFullscreen).toBe(false);
|
||||
await act(async () => {
|
||||
expect(await reader.latest.toggleFullscreen()).toBe(true);
|
||||
});
|
||||
expect(reader.request).toHaveBeenCalledTimes(1);
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
expect(reader.latest.isFullscreen).toBe(true);
|
||||
});
|
||||
|
||||
test("enter and exit affect only the owning reader, including external changes", async () => {
|
||||
const exit = platform();
|
||||
const first = await fullscreenReader("first");
|
||||
const second = await fullscreenReader("second");
|
||||
await act(async () => {
|
||||
await first.latest.toggleFullscreen();
|
||||
});
|
||||
expect(first.latest.isFullscreen).toBe(true);
|
||||
expect(second.latest.isFullscreen).toBe(false);
|
||||
await act(async () => {
|
||||
await first.latest.toggleFullscreen();
|
||||
});
|
||||
expect(exit).toHaveBeenCalledTimes(1);
|
||||
expect(first.latest.isFullscreen).toBe(false);
|
||||
await act(() => {
|
||||
owner = second.element;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
});
|
||||
expect(first.latest.isFullscreen).toBe(false);
|
||||
expect(second.latest.isFullscreen).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes an already-fullscreen container at mount", async () => {
|
||||
platform();
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
get: () => document.querySelector("[data-reader]"),
|
||||
});
|
||||
const reader = await fullscreenReader();
|
||||
expect(reader.latest.isFullscreen).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["request", "exit"])(
|
||||
"%s rejection is returned and exposed without an unhandled promise",
|
||||
async (operation) => {
|
||||
platform();
|
||||
const reader = await fullscreenReader();
|
||||
const denied = mock(async () => {
|
||||
throw new Error("Permission denied");
|
||||
});
|
||||
if (operation === "request")
|
||||
Object.defineProperty(reader.element!, "requestFullscreen", { value: denied });
|
||||
else {
|
||||
await act(() => {
|
||||
owner = reader.element;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
});
|
||||
Object.defineProperty(document, "exitFullscreen", { configurable: true, value: denied });
|
||||
}
|
||||
await act(async () => {
|
||||
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
||||
});
|
||||
expect(reader.latest.fullscreenError?.message).toBe("Permission denied");
|
||||
expect(reader.latest.isFullscreen).toBe(operation === "exit");
|
||||
},
|
||||
);
|
||||
|
||||
test("unsupported APIs and synchronous native exceptions are handled", async () => {
|
||||
platform();
|
||||
const reader = await fullscreenReader();
|
||||
Object.defineProperty(reader.element!, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
await act(async () => {
|
||||
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
||||
});
|
||||
expect(reader.latest.fullscreenError?.message).toContain("unavailable");
|
||||
Object.defineProperty(reader.element!, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw "Native failure";
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
||||
});
|
||||
expect(reader.latest.fullscreenError?.message).toBe("Native failure");
|
||||
});
|
||||
|
||||
test("a successful newer operation ignores an older rejection", async () => {
|
||||
platform();
|
||||
const reader = await fullscreenReader();
|
||||
let reject!: (reason: Error) => void;
|
||||
Object.defineProperty(reader.element!, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () =>
|
||||
new Promise<void>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
}),
|
||||
});
|
||||
let pending!: Promise<boolean>;
|
||||
await act(() => {
|
||||
pending = reader.latest.toggleFullscreen();
|
||||
});
|
||||
Object.defineProperty(reader.element!, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: reader.request,
|
||||
});
|
||||
await act(async () => {
|
||||
await reader.latest.toggleFullscreen();
|
||||
});
|
||||
await act(async () => {
|
||||
reject(new Error("Old failure"));
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
expect(reader.latest.isFullscreen).toBe(true);
|
||||
expect(reader.latest.fullscreenError).toBeNull();
|
||||
});
|
||||
|
||||
test("unmount removes listeners and ignores a late native failure", async () => {
|
||||
platform();
|
||||
const reader = await fullscreenReader();
|
||||
let reject!: (reason: Error) => void;
|
||||
Object.defineProperty(reader.element!, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () =>
|
||||
new Promise<void>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
}),
|
||||
});
|
||||
let pending!: Promise<boolean>;
|
||||
await act(() => {
|
||||
pending = reader.latest.toggleFullscreen();
|
||||
});
|
||||
await unmount(reader.mounted);
|
||||
const renders = reader.renders;
|
||||
await act(async () => {
|
||||
reject(new Error("Late failure"));
|
||||
expect(await pending).toBe(false);
|
||||
owner = reader.element;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
});
|
||||
expect(reader.renders).toBe(renders);
|
||||
expect(reader.latest.fullscreenError).toBeNull();
|
||||
});
|
||||
|
||||
test("an absent container is a harmless no-op", async () => {
|
||||
platform();
|
||||
const reader = await fullscreenReader("absent", false);
|
||||
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
||||
expect(reader.latest.fullscreenError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function tab(shiftKey = false) {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Tab",
|
||||
shiftKey,
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function Trap({
|
||||
active = true,
|
||||
onEscape,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onEscape?: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useFocusTrap(ref, active, onEscape);
|
||||
return (
|
||||
<section ref={ref} data-panel="" tabIndex={-1}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const controls = (
|
||||
<>
|
||||
<button data-first="">First</button>
|
||||
<button data-last="">Last</button>
|
||||
</>
|
||||
);
|
||||
|
||||
describe("reader modal keyboard lifecycle", () => {
|
||||
test("only the top modal handles keys and closing it restores the underlying modal", async () => {
|
||||
const lowerEscape = mock(() => {});
|
||||
const upperEscape = mock(() => {});
|
||||
const lower = await mount(<Trap onEscape={lowerEscape}>{controls}</Trap>);
|
||||
const lowerLast = lower.host.querySelector<HTMLButtonElement>("[data-last]")!;
|
||||
lowerLast.focus();
|
||||
const upper = await mount(<Trap onEscape={upperEscape}>{controls}</Trap>);
|
||||
tab(true);
|
||||
expect(document.activeElement).toBe(upper.host.querySelector("[data-last]"));
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(upperEscape).toHaveBeenCalledTimes(1);
|
||||
expect(lowerEscape).not.toHaveBeenCalled();
|
||||
await unmount(upper);
|
||||
expect(document.activeElement).toBe(lowerLast);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(lowerEscape).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("removing a lower modal preserves the upper scroll lock and original opener", async () => {
|
||||
const opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
extraElements.push(opener);
|
||||
opener.focus();
|
||||
document.body.style.overflow = "scroll";
|
||||
const lower = await mount(<Trap>{controls}</Trap>);
|
||||
const upper = await mount(<Trap>{controls}</Trap>);
|
||||
const focused = document.activeElement;
|
||||
await unmount(lower);
|
||||
expect(document.activeElement).toBe(focused);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
await unmount(upper);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
expect(document.body.style.overflow).toBe("scroll");
|
||||
});
|
||||
|
||||
test("Tab and Shift+Tab from the initial panel go to the first/last control", async () => {
|
||||
const mounted = await mount(<Trap>{controls}</Trap>);
|
||||
const panel = mounted.host.querySelector<HTMLElement>("[data-panel]")!;
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(tab(true).defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(mounted.host.querySelector("[data-last]"));
|
||||
panel.focus();
|
||||
expect(tab().defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(mounted.host.querySelector("[data-first]"));
|
||||
});
|
||||
|
||||
test("wraps each boundary while preserving ordinary browser tab navigation", async () => {
|
||||
const mounted = await mount(<Trap>{controls}</Trap>);
|
||||
const first = mounted.host.querySelector<HTMLButtonElement>("[data-first]")!;
|
||||
const last = mounted.host.querySelector<HTMLButtonElement>("[data-last]")!;
|
||||
first.focus();
|
||||
expect(tab().defaultPrevented).toBe(false);
|
||||
expect(tab(true).defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(last);
|
||||
expect(tab().defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
|
||||
test("skips hidden ancestors, inert regions, CSS-hidden, disabled and negative-tabindex controls", async () => {
|
||||
const mounted = await mount(
|
||||
<Trap>
|
||||
<div hidden>
|
||||
<button>Hidden ancestor</button>
|
||||
</div>
|
||||
<div style={{ display: "none" }}>
|
||||
<button>Display none</button>
|
||||
</div>
|
||||
<div style={{ visibility: "hidden" }}>
|
||||
<button>Visibility hidden</button>
|
||||
</div>
|
||||
<div inert>
|
||||
<button>Inert</button>
|
||||
</div>
|
||||
<button disabled>Disabled</button>
|
||||
<input type="hidden" />
|
||||
<button tabIndex={-2}>Negative</button>
|
||||
{controls}
|
||||
</Trap>,
|
||||
);
|
||||
tab(true);
|
||||
expect(document.activeElement).toBe(mounted.host.querySelector("[data-last]"));
|
||||
tab();
|
||||
expect(document.activeElement).toBe(mounted.host.querySelector("[data-first]"));
|
||||
});
|
||||
|
||||
test("a modal with no tabbable children keeps focus on its panel", async () => {
|
||||
const mounted = await mount(
|
||||
<Trap>
|
||||
<div hidden>
|
||||
<button>Hidden</button>
|
||||
</div>
|
||||
</Trap>,
|
||||
);
|
||||
expect(tab().defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(mounted.host.querySelector("[data-panel]"));
|
||||
expect(tab(true).defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test("focus lost outside the panel is recovered on the next Tab", async () => {
|
||||
const outside = document.createElement("button");
|
||||
document.body.append(outside);
|
||||
extraElements.push(outside);
|
||||
const mounted = await mount(<Trap>{controls}</Trap>);
|
||||
outside.focus();
|
||||
expect(tab(true).defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(mounted.host.querySelector("[data-last]"));
|
||||
});
|
||||
|
||||
test("rerender keeps focus, uses the current Escape callback and restores opener/scroll on close", async () => {
|
||||
const opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
extraElements.push(opener);
|
||||
opener.focus();
|
||||
document.body.style.overflow = "scroll";
|
||||
const original = mock(() => {});
|
||||
const replacement = mock(() => {});
|
||||
const mounted = await mount(<Trap onEscape={original}>{controls}</Trap>);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
const last = mounted.host.querySelector<HTMLButtonElement>("[data-last]")!;
|
||||
last.focus();
|
||||
await act(() => mounted.root.render(<Trap onEscape={replacement}>{controls}</Trap>));
|
||||
expect(document.activeElement).toBe(last);
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(original).not.toHaveBeenCalled();
|
||||
expect(replacement).toHaveBeenCalledTimes(1);
|
||||
await act(() =>
|
||||
mounted.root.render(
|
||||
<Trap active={false} onEscape={replacement}>
|
||||
{controls}
|
||||
</Trap>,
|
||||
),
|
||||
);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
expect(document.body.style.overflow).toBe("scroll");
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(replacement).toHaveBeenCalledTimes(1);
|
||||
expect(tab().defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Piège de focus pour une boîte de dialogue modale (WCAG 2.4.3 / 2.1.2).
|
||||
*
|
||||
* Keeps Tab inside the panel, closes on Escape, and restores focus on exit.
|
||||
* A click on non-focusable text moves focus back into the panel on the next Tab.
|
||||
*/
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
interface FocusLease {
|
||||
panel: HTMLElement;
|
||||
previousFocused: HTMLElement | null;
|
||||
previousTrap?: FocusLease;
|
||||
}
|
||||
|
||||
// The document scroll lock is shared; only the last activated modal handles keys.
|
||||
const activeTraps = new WeakMap<Document, { stack: FocusLease[]; previousOverflow: string }>();
|
||||
|
||||
/** Native tab stops, excluding controls hidden or disabled through their ancestors. */
|
||||
function isTabbable(element: HTMLElement): boolean {
|
||||
if (element.tabIndex < 0 || element.matches(":disabled, input[type='hidden']")) return false;
|
||||
if (element.closest("[hidden], [inert]")) return false;
|
||||
const view = element.ownerDocument.defaultView;
|
||||
const visibility = view?.getComputedStyle(element).visibility;
|
||||
if (visibility === "hidden" || visibility === "collapse") return false;
|
||||
for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) {
|
||||
if (view?.getComputedStyle(ancestor).display === "none") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useFocusTrap(
|
||||
panelRef: RefObject<HTMLElement | null>,
|
||||
active: boolean,
|
||||
onEscape?: () => void,
|
||||
): void {
|
||||
// `onEscape` est presque toujours une lambda écrite dans le JSX de l'appelant
|
||||
// (`() => setLightbox(false)`), donc une NOUVELLE fonction à chaque rendu. En
|
||||
// dépendance d'effet, elle rejouait tout le montage du piège à chaque rendu
|
||||
// de la modale : le nettoyage rendait le focus au déclencheur, l'effet
|
||||
// suivant le reprenait sur le panneau — l'utilisateur au clavier était donc
|
||||
// ramené en tête du panneau dès qu'un état changeait, et `prevFocused`
|
||||
// mémorisait le panneau au lieu du bouton d'ouverture, si bien que fermer ne
|
||||
// rendait plus le focus au bon endroit. On garde la fonction dans une ref :
|
||||
// l'effet ne dépend plus que de ce qui doit réellement le rejouer.
|
||||
const onEscapeRef = useRef(onEscape);
|
||||
useEffect(() => {
|
||||
onEscapeRef.current = onEscape;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const doc = panel.ownerDocument;
|
||||
let state = activeTraps.get(doc);
|
||||
if (!state) {
|
||||
state = { stack: [], previousOverflow: doc.body.style.overflow };
|
||||
activeTraps.set(doc, state);
|
||||
}
|
||||
const lease: FocusLease = {
|
||||
panel,
|
||||
previousFocused: doc.activeElement as HTMLElement | null,
|
||||
previousTrap: state.stack.at(-1),
|
||||
};
|
||||
state.stack.push(lease);
|
||||
// Le panneau doit porter `tabIndex={-1}` pour pouvoir recevoir le focus.
|
||||
panelRef.current?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (state.stack.at(-1) !== lease) return;
|
||||
if (e.key === "Escape") {
|
||||
onEscapeRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const currentPanel = panelRef.current;
|
||||
if (!currentPanel) return;
|
||||
const items = [...currentPanel.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabbable);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
currentPanel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0]!;
|
||||
const last = items[items.length - 1]!;
|
||||
const current = doc.activeElement as HTMLElement | null;
|
||||
if (!current || !items.includes(current)) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if (e.shiftKey && current === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && current === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
doc.addEventListener("keydown", onKey);
|
||||
doc.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
doc.removeEventListener("keydown", onKey);
|
||||
const wasTop = state.stack.at(-1) === lease;
|
||||
state.stack.splice(state.stack.indexOf(lease), 1);
|
||||
// Preserve the opener chain even when a lower modal is removed first.
|
||||
for (const remaining of state.stack) {
|
||||
if (remaining.previousTrap === lease) {
|
||||
remaining.previousTrap = lease.previousTrap;
|
||||
remaining.previousFocused = lease.previousFocused;
|
||||
}
|
||||
}
|
||||
const top = state.stack.at(-1);
|
||||
if (!top) {
|
||||
doc.body.style.overflow = state.previousOverflow;
|
||||
activeTraps.delete(doc);
|
||||
}
|
||||
if (wasTop) {
|
||||
const previous = lease.previousFocused;
|
||||
if (previous?.isConnected && (!top || top.panel.contains(previous))) previous.focus();
|
||||
else top?.panel.focus();
|
||||
}
|
||||
};
|
||||
}, [panelRef, active]);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Behaviour shared by `MangaReader` and `CaptionedReader`: reading mode, fullscreen, the Swiper
|
||||
* handle and the scroll-position sync of the virtualized vertical mode.
|
||||
*/
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import type { SwiperClass } from "swiper/react";
|
||||
|
||||
export type ViewMode = "paged" | "vertical";
|
||||
|
||||
/**
|
||||
* Above this many pages the thumbnail strip (one DOM node per page, up to ~282 for a whole volume)
|
||||
* is too heavy: a compact "go to page" control replaces it.
|
||||
*/
|
||||
export const SHOW_STRIP_MAX = 40;
|
||||
|
||||
/**
|
||||
* Fullscreen state of this container. The toggle resolves false on native failure;
|
||||
* `fullscreenError` exposes that failure without rejecting a button's event handler.
|
||||
*/
|
||||
export function useFullscreen(containerRef: RefObject<HTMLElement | null>) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [fullscreenError, setFullscreenError] = useState<Error | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const operation = useRef(0);
|
||||
|
||||
const toggleFullscreen = useCallback(async (): Promise<boolean> => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return false;
|
||||
const doc = el.ownerDocument;
|
||||
const current = ++operation.current;
|
||||
if (mounted.current) setFullscreenError(null);
|
||||
try {
|
||||
if (doc.fullscreenElement === el) {
|
||||
if (typeof doc.exitFullscreen !== "function")
|
||||
throw new Error("Fullscreen exit is unavailable");
|
||||
await doc.exitFullscreen();
|
||||
} else {
|
||||
// Request this container directly; never exit another component's fullscreen.
|
||||
if (typeof el.requestFullscreen !== "function")
|
||||
throw new Error("Fullscreen is unavailable");
|
||||
await el.requestFullscreen();
|
||||
}
|
||||
if (mounted.current && current === operation.current) {
|
||||
setIsFullscreen(
|
||||
Boolean(containerRef.current && doc.fullscreenElement === containerRef.current),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (reason) {
|
||||
if (mounted.current && current === operation.current) {
|
||||
setFullscreenError(reason instanceof Error ? reason : new Error(String(reason)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const doc = containerRef.current?.ownerDocument;
|
||||
if (!doc) return;
|
||||
mounted.current = true;
|
||||
const onChange = () =>
|
||||
setIsFullscreen(
|
||||
Boolean(containerRef.current && doc.fullscreenElement === containerRef.current),
|
||||
);
|
||||
onChange();
|
||||
doc.addEventListener("fullscreenchange", onChange);
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- operation est un compteur de génération (pas un nœud DOM) : l'incrément en cleanup invalide les bascules en vol
|
||||
operation.current++;
|
||||
doc.removeEventListener("fullscreenchange", onChange);
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
return { isFullscreen, fullscreenError, toggleFullscreen };
|
||||
}
|
||||
|
||||
/** The Swiper instance and the two callbacks that keep `current` in step with it. */
|
||||
export function useSwiperSync(setCurrent: (index: number) => void) {
|
||||
const swiperRef = useRef<SwiperClass | null>(null);
|
||||
|
||||
const handleSwiper = useCallback(
|
||||
(sw: SwiperClass) => {
|
||||
swiperRef.current = sw;
|
||||
setCurrent(sw.realIndex ?? sw.activeIndex ?? 0);
|
||||
},
|
||||
[setCurrent],
|
||||
);
|
||||
|
||||
const handleSlideChange = useCallback(
|
||||
(sw: SwiperClass) => setCurrent(sw.realIndex ?? sw.activeIndex ?? 0),
|
||||
[setCurrent],
|
||||
);
|
||||
|
||||
return { swiperRef, handleSwiper, handleSlideChange };
|
||||
}
|
||||
|
||||
/** In vertical mode, `current` follows the page at the middle of the scroll viewport. */
|
||||
export function useVerticalScrollSync(
|
||||
mode: ViewMode,
|
||||
scrollParentRef: RefObject<HTMLElement | null>,
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>,
|
||||
setCurrent: (index: number) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") return;
|
||||
const el = scrollParentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const mid = el.scrollTop + el.clientHeight / 2;
|
||||
const item = virtualizer.getVirtualItems().find((vi) => vi.start <= mid && vi.end >= mid);
|
||||
if (item) setCurrent(item.index);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [mode, scrollParentRef, virtualizer, setCurrent]);
|
||||
}
|
||||
|
||||
/** Absolute placement of one virtual row. */
|
||||
export const virtualRowStyle = (start: number): CSSProperties => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${start}px)`,
|
||||
});
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Scan the canonical reader utilities in every host, including isolated installs. */
|
||||
@source "./src";
|
||||
|
||||
/* Readers inherit the host Material 3 scheme; dark fallback keeps scanned pages legible. */
|
||||
.m3-reader {
|
||||
--reader-surface: var(--md-sys-color-surface, #111318);
|
||||
--reader-on-surface: var(--md-sys-color-on-surface, #e2e2e9);
|
||||
--reader-accent: var(--md-sys-color-primary, #bdc5ff);
|
||||
--reader-on-accent: var(--md-sys-color-on-primary, #17227a);
|
||||
--reader-outline: var(--md-sys-color-outline, #8e9099);
|
||||
color: var(--reader-on-surface);
|
||||
background: var(--reader-surface);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.m3-reader .m3-reader-hud {
|
||||
color: var(--reader-on-surface);
|
||||
background: var(--reader-surface);
|
||||
border-bottom: 2px solid var(--reader-outline);
|
||||
}
|
||||
.m3-reader .m3-reader-btn,
|
||||
.m3-reader .m3-reader-btn-active {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .375rem;
|
||||
min-height: 44px;
|
||||
padding: .35rem .75rem;
|
||||
border: 2px solid var(--reader-outline);
|
||||
border-radius: .6rem;
|
||||
color: var(--reader-on-surface);
|
||||
background: var(--reader-surface);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m3-reader .m3-reader-btn-active {
|
||||
color: var(--reader-on-accent);
|
||||
background: var(--reader-accent);
|
||||
border-color: var(--reader-accent);
|
||||
}
|
||||
.m3-reader :is(button, a, input):focus-visible {
|
||||
outline: 3px solid var(--reader-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.m3-reader :is(button, a):disabled { opacity: .45; cursor: default; }
|
||||
.m3-reader .m3-reader-scroll { scrollbar-color: var(--reader-outline) var(--reader-surface); }
|
||||
.m3-reader-collection { display: grid; gap: 1rem; padding: 1rem; border-radius: 1rem; }
|
||||
.m3-reader-collection-toolbar, .m3-reader-collection-navigation { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.m3-reader-collection-editions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.m3-reader-collection-contents { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); gap: .5rem; padding: 1rem; border: 2px solid var(--reader-outline); border-radius: 1rem; }
|
||||
.m3-reader-collection-empty { min-height: 8rem; padding: 2rem; border: 2px solid var(--reader-outline); border-radius: 1rem; font-size: 18px; }
|
||||
.m3-reader-collection-navigation { border-top: 1px solid var(--reader-outline); padding-top: .75rem; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m3-reader *, .m3-reader *::before, .m3-reader *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Crawlability of the readers' server-rendered markup (needs test/setup.ts for swiper).
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CollectionReader } from "../src/CollectionReader";
|
||||
import { MangaReader } from "../src/MangaReader";
|
||||
|
||||
const labels = {
|
||||
emptyLabel: "Empty",
|
||||
contentsLabel: "Contents",
|
||||
previousLabel: "Previous",
|
||||
nextLabel: "Next",
|
||||
};
|
||||
const editions = [
|
||||
{ id: "v1", label: "Volume 1", chapters: [{ id: 1, label: "Chapter 1", pageCount: 3 }] },
|
||||
];
|
||||
|
||||
describe("crawlable SSR markup (m3-reader)", () => {
|
||||
test("CollectionReader is a named region with hooks and no dangling aria-controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CollectionReader
|
||||
title="Collection"
|
||||
editions={editions}
|
||||
{...labels}
|
||||
renderChapter={() => <p>body</p>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="Collection"');
|
||||
expect(html).toContain('data-m3-component="CollectionReader"');
|
||||
expect(html).toContain('data-testid="collection-reader"');
|
||||
expect(html).not.toContain("aria-controls"); // the contents panel is not mounted while closed
|
||||
});
|
||||
|
||||
test("MangaReader is a labelled region whose pages are images with alternatives and whose chapter links are real", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MangaReader title="Chapter 1" pages={["a.jpg", "b.jpg"]} prevHref="/c/0" nextHref="/c/2" />,
|
||||
);
|
||||
expect(html).toContain('role="region"');
|
||||
expect(html).toContain('aria-label="Chapter 1"');
|
||||
expect(html).toContain('data-m3-component="MangaReader"');
|
||||
expect(html).toContain('data-testid="manga-reader"');
|
||||
expect(html).toContain('alt="Chapter 1 — page 1"');
|
||||
expect(html).toContain('href="/c/0"');
|
||||
expect(html).toContain('href="/c/2"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SSR and hydration smoke tests for the swiper-based readers (needs test/setup.ts).
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ReactElement } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup, renderToString } from "react-dom/server";
|
||||
|
||||
import { CaptionedReader } from "../src/CaptionedReader";
|
||||
import { MangaReader } from "../src/MangaReader";
|
||||
|
||||
async function hydrationErrors(element: ReactElement): Promise<unknown[]> {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
container.innerHTML = renderToString(element);
|
||||
const errors: unknown[] = [];
|
||||
const root = hydrateRoot(container, element, { onRecoverableError: (e) => errors.push(e) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
root.unmount();
|
||||
container.remove();
|
||||
return errors;
|
||||
}
|
||||
|
||||
const manga = <MangaReader title="Chapitre 1" pages={["p1.jpg", "p2.jpg"]} />;
|
||||
const captioned = (
|
||||
<CaptionedReader
|
||||
title="Recueil illustré"
|
||||
pages={[
|
||||
{ number: 1, image: "a.jpg", text: "texte" },
|
||||
{ number: 2, image: "b.jpg" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
describe("swiper-based readers", () => {
|
||||
test("MangaReader renders its title on the server", () => {
|
||||
expect(renderToStaticMarkup(manga)).toContain("Chapitre 1");
|
||||
});
|
||||
|
||||
test("CaptionedReader renders its title on the server", () => {
|
||||
expect(renderToStaticMarkup(captioned)).toContain("Recueil illustré");
|
||||
});
|
||||
|
||||
test("MangaReader hydrates without recoverable errors", async () => {
|
||||
expect(await hydrationErrors(manga)).toEqual([]);
|
||||
});
|
||||
|
||||
test("CaptionedReader hydrates without recoverable errors", async () => {
|
||||
expect(await hydrationErrors(captioned)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SSR and hydration smoke test for CollectionReader. MangaReader and CaptionedReader import
|
||||
// `swiper/react`, which imports `react` without declaring it as a peer, so Bun's isolated
|
||||
// linker cannot resolve it when they are loaded directly; they are not covered here.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup, renderToString } from "react-dom/server";
|
||||
|
||||
import { CollectionReader } from "../src/CollectionReader";
|
||||
|
||||
const labels = {
|
||||
emptyLabel: "Aucun contenu",
|
||||
contentsLabel: "Sommaire",
|
||||
previousLabel: "Précédent",
|
||||
nextLabel: "Suivant",
|
||||
};
|
||||
const editions = [
|
||||
{
|
||||
id: "vol-1",
|
||||
label: "Tome 1",
|
||||
chapters: [
|
||||
{ id: 1, label: "Chapitre 1", pageCount: 12 },
|
||||
{ id: 2, label: "Chapitre vide", pageCount: 0 },
|
||||
{ id: 3, label: "Chapitre 3", pageCount: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const element = (
|
||||
<CollectionReader
|
||||
title="Collection"
|
||||
editions={editions}
|
||||
{...labels}
|
||||
renderChapter={(_edition, chapter) => <p>contenu {chapter.label}</p>}
|
||||
/>
|
||||
);
|
||||
|
||||
describe("<CollectionReader>", () => {
|
||||
test("renders the first chapter on the server", () => {
|
||||
const html = renderToStaticMarkup(element);
|
||||
expect(html).toContain("contenu Chapitre 1");
|
||||
expect(html).toContain("Sommaire (2)");
|
||||
});
|
||||
|
||||
test("hides chapters without pages", () => {
|
||||
expect(renderToStaticMarkup(element)).not.toContain("Chapitre vide");
|
||||
});
|
||||
|
||||
test("renders the empty label when no edition has content", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CollectionReader title="Collection" editions={[]} {...labels} renderChapter={() => null} />,
|
||||
);
|
||||
expect(html).toContain("Aucun contenu");
|
||||
});
|
||||
|
||||
test("hydrates server markup without recoverable errors", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
container.innerHTML = renderToString(element);
|
||||
const errors: unknown[] = [];
|
||||
const root = hydrateRoot(container, element, { onRecoverableError: (e) => errors.push(e) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
root.unmount();
|
||||
container.remove();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// `swiper/react` imports `react` without declaring it as a peer, so Bun's isolated linker
|
||||
// cannot resolve it from inside swiper's store folder. Rewrite that import to this package's React.
|
||||
import { plugin } from "bun";
|
||||
|
||||
const react = Bun.resolveSync("react", import.meta.dir);
|
||||
|
||||
plugin({
|
||||
name: "swiper-react-peer",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /\/swiper@[^/]+\/node_modules\/swiper\/swiper-react\.mjs$/ },
|
||||
async (args) => {
|
||||
const source = await Bun.file(args.path).text();
|
||||
return {
|
||||
contents: source.replace(/from\s+(['"])react\1/g, `from ${JSON.stringify(react)}`),
|
||||
loader: "js",
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json", "@aphrody/m3-config/tsconfig.build.json"],
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@aphrody/m3-config/tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in new issue
Block a user