@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.
- package/README.md +12 -0
- package/dist/CollectionReader.d.ts +26 -0
- package/dist/CollectionReader.d.ts.map +1 -0
- package/dist/DatabookReader.d.ts +39 -0
- package/dist/DatabookReader.d.ts.map +1 -0
- package/dist/MangaReader.d.ts +36 -0
- package/dist/MangaReader.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1478 -0
- package/dist/index.js.map +14 -0
- package/dist/reader-lifecycle.test.d.ts +2 -0
- package/dist/reader-lifecycle.test.d.ts.map +1 -0
- package/dist/use-focus-trap.d.ts +9 -0
- package/dist/use-focus-trap.d.ts.map +1 -0
- package/dist/use-reader-core.d.ts +33 -0
- package/dist/use-reader-core.d.ts.map +1 -0
- package/dragon-ball.css +60 -0
- package/package.json +59 -0
- package/src/CollectionReader.tsx +82 -0
- package/src/DatabookReader.tsx +804 -0
- package/src/MangaReader.tsx +560 -0
- package/src/index.ts +7 -0
- package/src/reader-lifecycle.test.tsx +428 -0
- package/src/use-focus-trap.ts +129 -0
- package/src/use-reader-core.ts +138 -0
- package/styles.css +61 -0
|
@@ -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'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'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
|
+
}
|