@aphrody/m3-reader 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,804 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Lecteur de databook / interview — format manga + légende.
5
+ *
6
+ * Deux modes :
7
+ * - Vertical (défaut) : défilement webtoon, image puis texte en dessous.
8
+ * - Paginé : une planche à la fois (Swiper). Sur grand écran, layout
9
+ * planche | texte côte à côte pour que les légendes longues ne mangent
10
+ * plus l'image. Clic sur la planche → lightbox plein écran.
11
+ *
12
+ * Les pages sans image ni texte sont filtrées côté public (slots admin vides).
13
+ * LTR par défaut. Affiche le `number` éditorial (pas seulement l'index).
14
+ */
15
+
16
+ import type React from "react";
17
+ import type { ReactNode } from "react";
18
+ import { type ReactElement, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
19
+ import { useVirtualizer } from "@tanstack/react-virtual";
20
+ import { useFocusTrap } from "./use-focus-trap";
21
+ import {
22
+ BookOpen,
23
+ ChevronLeft,
24
+ ChevronRight,
25
+ Maximize2,
26
+ Minimize2,
27
+ ScrollText,
28
+ X,
29
+ ZoomIn,
30
+ } from "lucide-react";
31
+ import { Swiper, SwiperSlide } from "swiper/react";
32
+ import { Mousewheel, Virtual, Zoom } from "swiper/modules";
33
+
34
+ // eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
35
+ import "swiper/css";
36
+ // eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
37
+ import "swiper/css/zoom";
38
+ // eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
39
+ import "swiper/css/virtual";
40
+
41
+ import {
42
+ SHOW_STRIP_MAX,
43
+ type ViewMode,
44
+ useFullscreen,
45
+ useSwiperSync,
46
+ useVerticalScrollSync,
47
+ virtualRowStyle,
48
+ } from "./use-reader-core";
49
+
50
+ export type DatabookReaderPage = {
51
+ /** Numéro affiché (auto 1…N, modifiable côté admin). */
52
+ number?: number | null;
53
+ image?: string | null;
54
+ /**
55
+ * Transcription — présente pour les premières planches seulement.
56
+ * Au-delà, la fiche envoie `text: null` et `aDuTexte: true`, et le lecteur
57
+ * demande le texte au fournisseur de transcriptions au fil du défilement :
58
+ * embarquer les 313 transcriptions d'un Daizenshuu pesait 412 Ko de charge
59
+ * RSC pour une planche lue.
60
+ */
61
+ text?: string | null;
62
+ /** La planche PORTE une transcription, même si elle n'est pas encore chargée. */
63
+ aDuTexte?: boolean;
64
+ /** Transcription relue à l'image : pas d'avertissement de lecture douteuse. */
65
+ verifiee?: boolean;
66
+ };
67
+
68
+ export type ReaderTranscription = { text: string; page: number; verified: boolean };
69
+
70
+ export type DatabookReaderProps = {
71
+ pages: DatabookReaderPage[];
72
+ title: string;
73
+ resolveAssetUrl?: (path: string) => string;
74
+ imageSrc?: (url: string, width: number) => string;
75
+ imageSrcSet?: (url: string, widths: number[]) => string | null;
76
+ loadTranscriptions?: (pages: number[], signal: AbortSignal) => Promise<Record<string, string>>;
77
+ renderTranscription?: (transcription: ReaderTranscription) => ReactNode;
78
+ preloadCount?: number;
79
+ };
80
+
81
+ type ResolvedPage = {
82
+ /** Numéro affiché (édition admin) — pas forcément = position dans le lecteur. */
83
+ number: number;
84
+ imageUrl: string | null;
85
+ text: string | null;
86
+ /** Une transcription existe côté serveur, chargée ou non. */
87
+ aDuTexte: boolean;
88
+ /** Transcription acquittée à la main (drapeau porté par la fiche). */
89
+ verifiee: boolean;
90
+ };
91
+
92
+ function pageNumber(p: DatabookReaderPage, fallback: number): number {
93
+ const raw = p.number as unknown;
94
+ if (typeof raw === "number" && Number.isFinite(raw)) return Math.trunc(raw);
95
+ if (typeof raw === "string" && String(raw).trim() && Number.isFinite(Number(raw))) {
96
+ return Math.trunc(Number(raw));
97
+ }
98
+ return fallback;
99
+ }
100
+
101
+ function resolvePages(
102
+ pages: DatabookReaderPage[],
103
+ resolveAssetUrl: (path: string) => string,
104
+ ): ResolvedPage[] {
105
+ const out: ResolvedPage[] = [];
106
+ for (let i = 0; i < pages.length; i++) {
107
+ const p = pages[i];
108
+ const img = typeof p.image === "string" && p.image.trim() ? p.image.trim() : null;
109
+ const text = typeof p.text === "string" && p.text.trim() ? p.text.trim() : null;
110
+ const aDuTexte = p.aDuTexte === true || text !== null;
111
+ // Une planche sans image ET sans transcription est un emplacement vide
112
+ // réservé côté studio : elle ne se montre pas au public.
113
+ if (!img && !aDuTexte) continue;
114
+ out.push({
115
+ number: pageNumber(p, i + 1),
116
+ imageUrl: img ? resolveAssetUrl(img) : null,
117
+ text,
118
+ aDuTexte,
119
+ verifiee: p.verifiee === true,
120
+ });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Combien de planches autour de la courante on charge d'avance. */
126
+ const FENETRE_TEXTE = 6;
127
+
128
+ /**
129
+ * Transcriptions chargées à la demande.
130
+ *
131
+ * La fiche n'embarque que les premières ; celles qu'on approche sont demandées
132
+ * par fenêtres au fournisseur transmis par l'hôte. Une planche déjà connue n'est
133
+ * jamais redemandée, et un échec réseau laisse simplement la planche sans
134
+ * texte — jamais d'erreur jetée à la figure d'un lecteur qui feuillette.
135
+ */
136
+ function useTextesPlanches(
137
+ loadTranscriptions: DatabookReaderProps["loadTranscriptions"],
138
+ items: ResolvedPage[],
139
+ current: number,
140
+ ): Map<number, string> {
141
+ const [charges, setCharges] = useState<Map<number, string>>(() => new Map());
142
+ // Les numéros déjà demandés (succès ou échec) : sans cette mémoire, une
143
+ // planche sans transcription serait redemandée à chaque défilement.
144
+ const demandes = useRef<Set<number>>(new Set());
145
+
146
+ const manquants = useMemo(() => {
147
+ if (!loadTranscriptions) return [];
148
+ const de = Math.max(0, current - FENETRE_TEXTE);
149
+ const a = Math.min(items.length, current + FENETRE_TEXTE + 1);
150
+ const out: number[] = [];
151
+ for (let i = de; i < a; i++) {
152
+ const it = items[i];
153
+ if (!it || !it.aDuTexte || it.text !== null) continue;
154
+ if (charges.has(it.number) || demandes.current.has(it.number)) continue;
155
+ out.push(it.number);
156
+ }
157
+ return out;
158
+ }, [loadTranscriptions, items, current, charges]);
159
+
160
+ useEffect(() => {
161
+ if (manquants.length === 0) return;
162
+ for (const n of manquants) demandes.current.add(n);
163
+ const stop = new AbortController();
164
+ loadTranscriptions!(manquants, stop.signal)
165
+ .then((textes) => ({ textes }))
166
+ .then((d: { textes?: Record<string, string> }) => {
167
+ const recus = Object.entries(d.textes ?? {});
168
+ if (recus.length === 0) return undefined;
169
+ setCharges((prec) => {
170
+ const suiv = new Map(prec);
171
+ for (const [n, t] of recus) suiv.set(Number(n), t);
172
+ return suiv;
173
+ });
174
+ return undefined;
175
+ })
176
+ .catch(() => {
177
+ /* hors ligne ou navigation : la planche reste sans texte */
178
+ });
179
+ return () => stop.abort();
180
+ }, [manquants, loadTranscriptions]);
181
+
182
+ return charges;
183
+ }
184
+
185
+ export function DatabookReader({
186
+ pages,
187
+ title,
188
+ resolveAssetUrl = (path) => path,
189
+ imageSrc = (url) => url,
190
+ imageSrcSet = () => null,
191
+ loadTranscriptions,
192
+ renderTranscription,
193
+ preloadCount = 4,
194
+ }: DatabookReaderProps): ReactElement {
195
+ const containerRef = useRef<HTMLDivElement>(null);
196
+ const lightboxRef = useRef<HTMLDivElement>(null);
197
+ const baseId = useId();
198
+
199
+ const items = useMemo(() => resolvePages(pages, resolveAssetUrl), [pages, resolveAssetUrl]);
200
+ const total = items.length;
201
+
202
+ const [mode, setMode] = useState<ViewMode>("vertical");
203
+ const [current, setCurrent] = useState(0);
204
+ const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
205
+ const { swiperRef, handleSwiper, handleSlideChange } = useSwiperSync(setCurrent);
206
+ /** Lightbox plein écran sur la planche courante (indépendant du mode). */
207
+ const [lightbox, setLightbox] = useState(false);
208
+
209
+ const textesCharges = useTextesPlanches(loadTranscriptions, items, current);
210
+ /** Le texte d'une planche : celui de la fiche, sinon celui chargé après coup. */
211
+ const texteDe = useCallback(
212
+ (p: ResolvedPage | undefined): string | null =>
213
+ p ? (p.text ?? textesCharges.get(p.number) ?? null) : null,
214
+ [textesCharges],
215
+ );
216
+ useFocusTrap(lightboxRef, lightbox, () => setLightbox(false));
217
+
218
+ // Clamp l'index si le nombre de pages change (édition live / revalidation).
219
+ useEffect(() => {
220
+ if (current >= total && total > 0) setCurrent(total - 1);
221
+ if (total === 0) setCurrent(0);
222
+ }, [total, current]);
223
+
224
+ // Précharge planche courante ± voisines.
225
+ useEffect(() => {
226
+ if (total === 0) return;
227
+ for (const idx of [current - 1, current, current + 1, current + 2]) {
228
+ const url = items[idx]?.imageUrl;
229
+ if (!url) continue;
230
+ const img = new Image();
231
+ img.src = url;
232
+ }
233
+ }, [current, items, total]);
234
+
235
+ // Détection côté client uniquement (`document` absent au rendu serveur).
236
+ const [fullscreenDispo, setFullscreenDispo] = useState(false);
237
+ useEffect(() => {
238
+ setFullscreenDispo(
239
+ typeof document !== "undefined" &&
240
+ document.fullscreenEnabled &&
241
+ typeof containerRef.current?.requestFullscreen === "function",
242
+ );
243
+ }, []);
244
+
245
+ /* ------------------------------ Vertical ------------------------------ */
246
+
247
+ const scrollParentRef = useRef<HTMLDivElement>(null);
248
+ const virtualizer = useVirtualizer({
249
+ count: total,
250
+ getScrollElement: () => scrollParentRef.current,
251
+ estimateSize: () =>
252
+ typeof window === "undefined" ? 1100 : Math.round(window.innerHeight * 0.95),
253
+ // Marge de préchargement réglée sur les capacités : 4 planches rendent le
254
+ // défilement continu sur un scan de 1 à 2 Mo, mais les précharger sur un
255
+ // mobile en 2G dépense des données pour des pages qu'on ne verra
256
+ // peut-être jamais. `prechargement` vaut 1 sur réseau lent, 6 sur une
257
+ // machine confortable.
258
+ overscan: Math.max(2, preloadCount),
259
+ });
260
+
261
+ const goTo = useCallback(
262
+ (idx: number) => {
263
+ const i = Math.min(Math.max(idx, 0), Math.max(0, total - 1));
264
+ setCurrent(i);
265
+ if (mode === "paged") swiperRef.current?.slideTo(i);
266
+ else virtualizer.scrollToIndex(i, { align: "start" });
267
+ },
268
+ [mode, total, virtualizer],
269
+ );
270
+
271
+ useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent);
272
+
273
+ // Navigation clavier des planches.
274
+ //
275
+ // Une SEULE source de vérité : ce handler, qui passe par `goTo` (donc
276
+ // `slideTo` en mode paginé). Le module `Keyboard` de Swiper est
277
+ // volontairement absent : il écoute lui aussi `keydown`, sur `document`,
278
+ // et faisait AVANCER DEUX FOIS — Swiper glissait d'une planche et émettait
279
+ // `slideChange` (`setCurrent(index)`), puis ce handler ajoutait +1 par-dessus.
280
+ // `current` se décalait durablement de la planche affichée, si bien que la
281
+ // transcription montrée à côté du scan était celle d'une AUTRE page (mesuré
282
+ // sur /wiki/databooks/305 : compteur « n°3 · 3/20 » sur la planche 2).
283
+ useEffect(() => {
284
+ const actif = lightbox || mode === "paged";
285
+ if (!actif) return;
286
+ const onKey = (e: KeyboardEvent) => {
287
+ const cible = e.target as HTMLElement | null;
288
+ const tag = cible?.tagName;
289
+ if (tag === "INPUT" || tag === "TEXTAREA" || cible?.isContentEditable) return;
290
+ switch (e.key) {
291
+ case "Escape":
292
+ if (lightbox) setLightbox(false);
293
+ return;
294
+ case "ArrowLeft":
295
+ e.preventDefault();
296
+ goTo(current - 1);
297
+ return;
298
+ case "ArrowRight":
299
+ e.preventDefault();
300
+ goTo(current + 1);
301
+ return;
302
+ case "Home":
303
+ e.preventDefault();
304
+ goTo(0);
305
+ return;
306
+ case "End":
307
+ e.preventDefault();
308
+ goTo(total - 1);
309
+ return;
310
+ }
311
+ };
312
+ window.addEventListener("keydown", onKey);
313
+ return () => window.removeEventListener("keydown", onKey);
314
+ }, [lightbox, mode, total, current, goTo]);
315
+
316
+ const goPrevPage = useCallback(() => {
317
+ goTo(current - 1);
318
+ }, [current, goTo]);
319
+
320
+ const goNextPage = useCallback(() => {
321
+ goTo(current + 1);
322
+ }, [current, goTo]);
323
+
324
+ const openLightbox = useCallback(
325
+ (idx?: number) => {
326
+ if (typeof idx === "number") setCurrent(idx);
327
+ const item = items[typeof idx === "number" ? idx : current];
328
+ if (item?.imageUrl) setLightbox(true);
329
+ },
330
+ [items, current],
331
+ );
332
+
333
+ // `min-h-10`/`min-w-10` (40 px) : les commandes du lecteur mesuraient 28 à
334
+ // 30 px de haut, sous le seuil tactile. Le padding reste serré à partir de
335
+ // `sm`, où l'on pointe à la souris.
336
+ // Balayage horizontal dans la visionneuse : sur mobile elle n'offrait que deux
337
+ // flèches de 44 px posées sur l'image — le geste naturel (faire glisser la
338
+ // planche) ne faisait rien. Seuil de 48 px, et on ignore les gestes
339
+ // majoritairement verticaux pour ne pas voler un défilement.
340
+ const toucheRef = useRef<{ x: number; y: number } | null>(null);
341
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
342
+ const t = e.touches[0];
343
+ toucheRef.current = t ? { x: t.clientX, y: t.clientY } : null;
344
+ }, []);
345
+ const onTouchEnd = useCallback(
346
+ (e: React.TouchEvent) => {
347
+ const depart = toucheRef.current;
348
+ toucheRef.current = null;
349
+ const t = e.changedTouches[0];
350
+ if (!depart || !t) return;
351
+ const dx = t.clientX - depart.x;
352
+ const dy = t.clientY - depart.y;
353
+ if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return;
354
+ // `goTo` et non `setCurrent` : il fait suivre le Swiper sous-jacent,
355
+ // sans quoi refermer la visionneuse laisserait la planche paginée sur
356
+ // l'ancienne page.
357
+ goTo(dx < 0 ? current + 1 : current - 1);
358
+ },
359
+ [current, goTo],
360
+ );
361
+
362
+ const currentItem = items[current];
363
+ const currentNum = currentItem?.number ?? current + 1;
364
+ const counter = total ? `n°${currentNum} · ${Math.min(current + 1, total)}/${total}` : "0 / 0";
365
+ const currentText = texteDe(currentItem);
366
+ const currentImage = currentItem?.imageUrl ?? null;
367
+
368
+ if (total === 0) {
369
+ return (
370
+ <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">
371
+ Aucune page à afficher pour le moment.
372
+ </div>
373
+ );
374
+ }
375
+
376
+ const legendBlock = (
377
+ <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)]">
378
+ <div className="mb-2 shrink-0 flex items-center justify-between">
379
+ <span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black">
380
+ <span>PLANCHE {currentNum}</span>
381
+ </span>
382
+ <span className="text-[18px] font-bold tabular-nums text-[var(--reader-on-surface)]/60">
383
+ {current + 1} / {total}
384
+ </span>
385
+ </div>
386
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1 m3-reader-scroll">
387
+ {currentText ? (
388
+ (renderTranscription?.({
389
+ text: currentText,
390
+ page: currentNum,
391
+ verified: currentItem?.verifiee ?? false,
392
+ }) ?? currentText)
393
+ ) : currentItem?.aDuTexte ? (
394
+ // La planche PORTE une transcription : dire « aucune description »
395
+ // pendant qu'elle arrive ferait croire à une lacune du corpus.
396
+ <p className="text-[18px] italic text-[var(--reader-on-surface)]/50">
397
+ Chargement de la transcription…
398
+ </p>
399
+ ) : (
400
+ <p className="text-[18px] italic text-[var(--reader-on-surface)]/50">
401
+ Aucune description pour cette page.
402
+ </p>
403
+ )}
404
+ </div>
405
+ </div>
406
+ );
407
+
408
+ return (
409
+ <div
410
+ ref={containerRef}
411
+ 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"
412
+ data-databook-reader
413
+ data-m3-component="DatabookReader"
414
+ data-testid="databook-reader"
415
+ role="region"
416
+ aria-label={title}
417
+ >
418
+ {/* Barre de contrôles Toriyama HUD */}
419
+ <div className="m3-reader-hud z-10 flex flex-nowrap items-center gap-2 px-3 py-2.5 sm:flex-wrap">
420
+ <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">
421
+ {title}
422
+ </span>
423
+ <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">
424
+ <span>{counter}</span>
425
+ </span>
426
+
427
+ <div className="ml-auto flex shrink-0 items-center gap-1.5 sm:ml-0 sm:gap-2">
428
+ <button
429
+ type="button"
430
+ className={mode === "vertical" ? "m3-reader-btn-active" : "m3-reader-btn"}
431
+ onClick={() => setMode("vertical")}
432
+ aria-pressed={mode === "vertical"}
433
+ aria-label="Mode vertical (planche + texte)"
434
+ title="Mode vertical (planche + texte)"
435
+ >
436
+ <ScrollText size={14} aria-hidden />
437
+ <span className="hidden sm:inline">Défilement</span>
438
+ </button>
439
+ <button
440
+ type="button"
441
+ className={mode === "paged" ? "m3-reader-btn-active" : "m3-reader-btn"}
442
+ onClick={() => setMode("paged")}
443
+ aria-pressed={mode === "paged"}
444
+ aria-label="Mode paginé"
445
+ title="Mode paginé"
446
+ >
447
+ <BookOpen size={14} aria-hidden />
448
+ <span className="hidden sm:inline">Paginé</span>
449
+ </button>
450
+ <button
451
+ type="button"
452
+ className="m3-reader-btn"
453
+ onClick={() => openLightbox()}
454
+ disabled={!currentImage}
455
+ aria-label="Agrandir la planche"
456
+ title="Agrandir la planche"
457
+ >
458
+ <ZoomIn size={14} aria-hidden />
459
+ </button>
460
+ {fullscreenDispo && (
461
+ <button
462
+ type="button"
463
+ className="m3-reader-btn"
464
+ onClick={toggleFullscreen}
465
+ aria-label={isFullscreen ? "Quitter le plein écran" : "Activer le plein écran"}
466
+ title={isFullscreen ? "Quitter le plein écran" : "Plein écran"}
467
+ >
468
+ {isFullscreen ? (
469
+ <Minimize2 size={14} aria-hidden />
470
+ ) : (
471
+ <Maximize2 size={14} aria-hidden />
472
+ )}
473
+ </button>
474
+ )}
475
+ </div>
476
+ </div>
477
+
478
+ {/* Zone de lecture */}
479
+ <div className="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[var(--reader-surface)]">
480
+ {mode === "paged" ? (
481
+ /* Layout côte à côte ≥ lg : planche fixe à gauche, texte scrollable
482
+ à droite — les légendes longues ne réduisent plus l'image. */
483
+ <div className="flex h-full min-h-0 flex-col lg:flex-row">
484
+ {/* Zone planche */}
485
+ <div className="relative min-h-[52%] min-w-0 flex-[1.6] lg:h-full lg:min-h-0 lg:flex-[1.15]">
486
+ <Swiper
487
+ key="databook-ltr"
488
+ initialSlide={current}
489
+ dir="ltr"
490
+ modules={[Zoom, Virtual, Mousewheel]}
491
+ className="h-full w-full"
492
+ slidesPerView={1}
493
+ mousewheel={{ forceToAxis: true }}
494
+ zoom={{ maxRatio: 3, toggle: true }}
495
+ virtual
496
+ onSwiper={handleSwiper}
497
+ onSlideChange={handleSlideChange}
498
+ >
499
+ {items.map((item, i) => (
500
+ <SwiperSlide
501
+ key={`db-page-${item.number}-${i}`}
502
+ virtualIndex={i}
503
+ zoom={Boolean(item.imageUrl)}
504
+ className="flex items-center justify-center"
505
+ >
506
+ <div className="swiper-zoom-container flex h-full w-full items-center justify-center bg-[var(--reader-surface)]">
507
+ {item.imageUrl ? (
508
+ <button
509
+ type="button"
510
+ className="group relative flex h-full w-full cursor-zoom-in items-center justify-center border-0 bg-transparent p-0"
511
+ onClick={() => openLightbox(i)}
512
+ aria-label={`Agrandir la page ${item.number}`}
513
+ >
514
+ <img
515
+ src={imageSrc(item.imageUrl, 1080)}
516
+ srcSet={imageSrcSet(item.imageUrl, [828, 1080, 1920]) || undefined}
517
+ sizes="(min-width: 1024px) 900px, 100vw"
518
+ alt={`${title} — page ${item.number}`}
519
+ loading={Math.abs(i - current) <= 1 ? "eager" : "lazy"}
520
+ draggable={false}
521
+ className="max-h-full max-w-full object-contain transition-opacity group-hover:opacity-95"
522
+ />
523
+ <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">
524
+ Agrandir
525
+ </span>
526
+ </button>
527
+ ) : (
528
+ <div className="px-6 text-center text-[18px] text-[var(--reader-on-surface)]/50">
529
+ Page {item.number} — pas d&apos;image
530
+ </div>
531
+ )}
532
+ </div>
533
+ </SwiperSlide>
534
+ ))}
535
+ </Swiper>
536
+
537
+ <button
538
+ type="button"
539
+ aria-label="Page précédente"
540
+ onClick={goPrevPage}
541
+ disabled={current <= 0}
542
+ 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"
543
+ >
544
+ <ChevronLeft size={20} aria-hidden />
545
+ </button>
546
+ <button
547
+ type="button"
548
+ aria-label="Page suivante"
549
+ onClick={goNextPage}
550
+ disabled={current >= total - 1}
551
+ 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"
552
+ >
553
+ <ChevronRight size={20} aria-hidden />
554
+ </button>
555
+ </div>
556
+
557
+ {/* Légende — panneau scrollable indépendant (ne compresse plus l'image). */}
558
+ <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">
559
+ {legendBlock}
560
+ </div>
561
+ </div>
562
+ ) : (
563
+ <div
564
+ ref={scrollParentRef}
565
+ tabIndex={0}
566
+ // `overscroll-y-auto` et non `contain` : arrivé en bout de lecteur,
567
+ // la molette doit rendre la main au document. Avec `contain`, le
568
+ // lecteur avalait le geste et la page restait bloquée — on ne
569
+ // pouvait plus descendre vers la suite de la fiche sans sortir la
570
+ // souris de la zone. Le risque d'origine (l'inertie mobile qui
571
+ // éjectait le lecteur en pleine lecture) est traité autrement : les
572
+ // planches s'accrochent au défilement, donc le mouvement s'arrête
573
+ // franchement sur une page au lieu de filer.
574
+ //
575
+ // `snap-y snap-proximity` : le « vrai effet de page » — le
576
+ // défilement s'arrête franchement sur une planche au lieu de
577
+ // s'immobiliser entre deux images. `proximity` et non `mandatory` :
578
+ // la liste est virtualisée (éléments montés/démontés en cours de
579
+ // route, hauteurs mesurées après coup), et une accroche
580
+ // obligatoire se bat avec ces remesures — elle produit des sauts au
581
+ // moment où une planche entre dans la fenêtre.
582
+ 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)]"
583
+ aria-label={`${title} — lecture verticale`}
584
+ >
585
+ <div
586
+ style={{
587
+ height: virtualizer.getTotalSize(),
588
+ width: "100%",
589
+ position: "relative",
590
+ }}
591
+ >
592
+ {virtualizer.getVirtualItems().map((vi) => {
593
+ const item = items[vi.index];
594
+ if (!item) return null;
595
+ return (
596
+ <div
597
+ key={`${baseId}-${vi.key}`}
598
+ data-index={vi.index}
599
+ ref={virtualizer.measureElement}
600
+ style={virtualRowStyle(vi.start)}
601
+ className="snap-start snap-always border-b border-white/5 px-2 py-4 sm:px-4 sm:py-6"
602
+ >
603
+ <div className="group/planche mx-auto flex w-full max-w-3xl flex-col gap-3">
604
+ <span className="text-[18px] font-bold uppercase tracking-[0.2em] text-[var(--reader-accent)]/70">
605
+ Page {item.number}
606
+ </span>
607
+ {item.imageUrl ? (
608
+ <button
609
+ type="button"
610
+ className="group relative cursor-zoom-in border-0 bg-transparent p-0 text-left focus-visible:outline-none"
611
+ onClick={() => openLightbox(vi.index)}
612
+ aria-label={`Ouvrir la page ${item.number} en plein écran`}
613
+ >
614
+ <img
615
+ src={imageSrc(item.imageUrl, 1080)}
616
+ srcSet={imageSrcSet(item.imageUrl, [828, 1080, 1920]) || undefined}
617
+ sizes="(min-width: 768px) 768px, 100vw"
618
+ alt={`${title} — page ${item.number}`}
619
+ // Trois planches de part et d'autre chargées d'avance :
620
+ // avec une seule, tourner la page laissait un rectangle
621
+ // vide le temps du téléchargement d'un scan de 1 à 2 Mo.
622
+ loading={Math.abs(vi.index - current) <= 3 ? "eager" : "lazy"}
623
+ // `high` sur la planche courante : le navigateur la sert
624
+ // avant les voisines préchargées.
625
+ fetchPriority={vi.index === current ? "high" : "auto"}
626
+ decoding="async"
627
+ draggable={false}
628
+ 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"
629
+ />
630
+ {/* Appel au plein écran, révélé au survol de la planche. */}
631
+ <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">
632
+ <ZoomIn size={12} aria-hidden />
633
+ Plein écran
634
+ </span>
635
+ </button>
636
+ ) : (
637
+ <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">
638
+ Pas d&apos;image pour cette page
639
+ </div>
640
+ )}
641
+ {texteDe(item) ? (
642
+ <div className="rounded-md border border-[var(--reader-outline)]/40 bg-[var(--reader-surface)]/50 px-4 py-3">
643
+ {renderTranscription?.({
644
+ text: texteDe(item) as string,
645
+ page: item.number,
646
+ verified: item.verifiee,
647
+ }) ?? texteDe(item)}
648
+ </div>
649
+ ) : null}
650
+ </div>
651
+ </div>
652
+ );
653
+ })}
654
+ </div>
655
+ </div>
656
+ )}
657
+ </div>
658
+
659
+ {/* Bande de vignettes (databooks courts). */}
660
+ {mode === "paged" && total > 0 && total <= SHOW_STRIP_MAX && (
661
+ <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">
662
+ <div className="mx-auto flex gap-2">
663
+ {items.map((item, idx) => (
664
+ <button
665
+ key={`thumb-${item.number}-${idx}`}
666
+ type="button"
667
+ onClick={() => goTo(idx)}
668
+ className={`relative h-16 w-12 flex-shrink-0 overflow-hidden rounded border-2 transition-all ${
669
+ current === idx
670
+ ? "scale-105 border-[var(--reader-accent)] shadow-[0_0_10px_rgba(255,178,0,0.4)]"
671
+ : "border-white/10 hover:border-white/30"
672
+ }`}
673
+ title={`Page ${item.number}`}
674
+ aria-label={`Aller à la page ${item.number}`}
675
+ aria-current={current === idx ? "true" : undefined}
676
+ >
677
+ {item.imageUrl ? (
678
+ <img
679
+ // Vignette de 48×64 px : elle chargeait le scan source (jusqu'à
680
+ // 5 Mio) pour un timbre-poste, jusqu'à 40 fois par fiche.
681
+ src={imageSrc(item.imageUrl, 96)}
682
+ alt=""
683
+ className="h-full w-full object-cover"
684
+ loading="lazy"
685
+ />
686
+ ) : (
687
+ <span className="flex h-full w-full items-center justify-center bg-zinc-900 text-[18px] text-[var(--reader-on-surface)]/50">
688
+ txt
689
+ </span>
690
+ )}
691
+ <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)]">
692
+ {item.number}
693
+ </span>
694
+ </button>
695
+ ))}
696
+ </div>
697
+ </div>
698
+ )}
699
+
700
+ {/* Saut direct pour les longs databooks. */}
701
+ {mode === "paged" && total > SHOW_STRIP_MAX && (
702
+ <form
703
+ 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"
704
+ onSubmit={(e) => {
705
+ e.preventDefault();
706
+ const field = e.currentTarget.elements.namedItem("goto") as HTMLInputElement | null;
707
+ const wanted = Number(field?.value);
708
+ if (!Number.isFinite(wanted)) return;
709
+ // Cherche d'abord par numéro éditorial, sinon par position 1-based.
710
+ const byNumber = items.findIndex((it) => it.number === Math.trunc(wanted));
711
+ if (byNumber >= 0) goTo(byNumber);
712
+ else goTo(Math.trunc(wanted) - 1);
713
+ }}
714
+ >
715
+ <label
716
+ htmlFor={`${baseId}-goto`}
717
+ className="text-[18px] font-black uppercase tracking-wider text-[var(--reader-on-surface)]"
718
+ >
719
+ Aller à n°
720
+ </label>
721
+ <input
722
+ id={`${baseId}-goto`}
723
+ name="goto"
724
+ type="number"
725
+ key={currentNum}
726
+ defaultValue={currentNum}
727
+ 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)]"
728
+ />
729
+ <button type="submit" className="m3-reader-btn-active !py-1 !px-3.5">
730
+ OK
731
+ </button>
732
+ </form>
733
+ )}
734
+
735
+ {/* Lightbox agrandissement planche */}
736
+ {lightbox && currentImage && (
737
+ <div
738
+ ref={lightboxRef}
739
+ // `tabIndex={-1}` : requis par `useFocusTrap`, qui donne le focus au
740
+ // panneau à l'ouverture. Sans piège, la tabulation continuait DERRIÈRE
741
+ // la visionneuse plein écran — le focus disparaissait pour l'utilisateur
742
+ // clavier et pour le lecteur d'écran.
743
+ tabIndex={-1}
744
+ role="dialog"
745
+ aria-modal="true"
746
+ aria-label={`${title} — page ${currentNum} agrandie`}
747
+ className="fixed inset-0 z-[100] flex flex-col bg-[var(--reader-surface)]/95"
748
+ onClick={() => setLightbox(false)}
749
+ >
750
+ <div className="flex items-center gap-3 border-b-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-4 py-3">
751
+ <span className="min-w-0 flex-1 truncate font-sans text-[18px] font-extrabold uppercase tracking-wide text-[var(--reader-on-surface)]">
752
+ {title}
753
+ </span>
754
+ <span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black">
755
+ <span>n°{currentNum}</span>
756
+ </span>
757
+ <button
758
+ type="button"
759
+ className="m3-reader-btn ml-auto"
760
+ onClick={(e) => {
761
+ e.stopPropagation();
762
+ setLightbox(false);
763
+ }}
764
+ aria-label="Fermer"
765
+ >
766
+ <X size={16} aria-hidden />
767
+ </button>
768
+ </div>
769
+ <div
770
+ className="relative flex min-h-0 flex-1 items-center justify-center p-2 sm:p-4"
771
+ onClick={(e) => e.stopPropagation()}
772
+ onTouchStart={onTouchStart}
773
+ onTouchEnd={onTouchEnd}
774
+ >
775
+ <img
776
+ src={currentImage}
777
+ alt={`${title} — page ${currentNum}`}
778
+ className="max-h-full max-w-full object-contain"
779
+ draggable={false}
780
+ />
781
+ <button
782
+ type="button"
783
+ aria-label="Page précédente"
784
+ onClick={() => goTo(current - 1)}
785
+ disabled={current <= 0}
786
+ 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"
787
+ >
788
+ <ChevronLeft size={22} aria-hidden />
789
+ </button>
790
+ <button
791
+ type="button"
792
+ aria-label="Page suivante"
793
+ onClick={() => goTo(current + 1)}
794
+ disabled={current >= total - 1}
795
+ 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"
796
+ >
797
+ <ChevronRight size={22} aria-hidden />
798
+ </button>
799
+ </div>
800
+ </div>
801
+ )}
802
+ </div>
803
+ );
804
+ }