@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,560 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lecteur de scan manga complet, 100 % client.
|
|
5
|
+
*
|
|
6
|
+
* Deux modes togglables :
|
|
7
|
+
* - Paginé : Swiper (Keyboard, Zoom, Virtual, Mousewheel), RTL réel,
|
|
8
|
+
* double-page optionnelle sur grand écran.
|
|
9
|
+
* - Vertical : défilement webtoon virtualisé via `@tanstack/react-virtual`
|
|
10
|
+
* (mesure dynamique des planches, overscan raisonnable).
|
|
11
|
+
*
|
|
12
|
+
* URL resolution and chapter persistence are supplied by the host.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type ReactElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
useCallback,
|
|
19
|
+
useEffect,
|
|
20
|
+
useId,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
} from "react";
|
|
25
|
+
|
|
26
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
27
|
+
import {
|
|
28
|
+
BookOpen,
|
|
29
|
+
ChevronLeft,
|
|
30
|
+
ChevronRight,
|
|
31
|
+
Columns2,
|
|
32
|
+
Maximize2,
|
|
33
|
+
Minimize2,
|
|
34
|
+
MoveHorizontal,
|
|
35
|
+
ScrollText,
|
|
36
|
+
Square,
|
|
37
|
+
} from "lucide-react";
|
|
38
|
+
import { Swiper, SwiperSlide } from "swiper/react";
|
|
39
|
+
import { Keyboard, Mousewheel, Zoom } from "swiper/modules";
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
|
42
|
+
import "swiper/css";
|
|
43
|
+
// eslint-disable-next-line import/no-unassigned-import -- Swiper layout and interactions require this stylesheet.
|
|
44
|
+
import "swiper/css/zoom";
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
SHOW_STRIP_MAX,
|
|
48
|
+
type ViewMode,
|
|
49
|
+
useFullscreen,
|
|
50
|
+
useSwiperSync,
|
|
51
|
+
useVerticalScrollSync,
|
|
52
|
+
virtualRowStyle,
|
|
53
|
+
} from "./use-reader-core";
|
|
54
|
+
|
|
55
|
+
export type MangaReaderProps = {
|
|
56
|
+
pages: string[];
|
|
57
|
+
title: string;
|
|
58
|
+
direction?: "rtl" | "ltr";
|
|
59
|
+
prevHref?: string | null;
|
|
60
|
+
nextHref?: string | null;
|
|
61
|
+
prevPages?: string[] | null;
|
|
62
|
+
nextPages?: string[] | null;
|
|
63
|
+
initialPage?: number;
|
|
64
|
+
onPageChange?: (page: number) => void;
|
|
65
|
+
apiPageHref?: (page: number) => string | null;
|
|
66
|
+
resolveAssetUrl?: (path: string) => string;
|
|
67
|
+
resolveMirrorUrl?: (url: string) => string | null;
|
|
68
|
+
renderChapterLink?: (props: {
|
|
69
|
+
href: string;
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
className: string;
|
|
72
|
+
ariaLabel: string;
|
|
73
|
+
}) => ReactNode;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const LG_BREAKPOINT = 1024; // Tailwind `lg`
|
|
77
|
+
|
|
78
|
+
/** Détecte le passage sous/au-delà du breakpoint `lg` pour la double-page. */
|
|
79
|
+
function useIsLargeScreen(): boolean {
|
|
80
|
+
const [isLarge, setIsLarge] = useState(false);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
|
|
83
|
+
const sync = () => setIsLarge(mql.matches);
|
|
84
|
+
sync();
|
|
85
|
+
mql.addEventListener("change", sync);
|
|
86
|
+
return () => mql.removeEventListener("change", sync);
|
|
87
|
+
}, []);
|
|
88
|
+
return isLarge;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function MangaReader({
|
|
92
|
+
pages,
|
|
93
|
+
title,
|
|
94
|
+
direction = "rtl",
|
|
95
|
+
prevHref = null,
|
|
96
|
+
nextHref = null,
|
|
97
|
+
prevPages = null,
|
|
98
|
+
nextPages = null,
|
|
99
|
+
initialPage = 1,
|
|
100
|
+
onPageChange,
|
|
101
|
+
apiPageHref,
|
|
102
|
+
resolveAssetUrl = (path) => path,
|
|
103
|
+
resolveMirrorUrl,
|
|
104
|
+
renderChapterLink,
|
|
105
|
+
}: MangaReaderProps): ReactElement {
|
|
106
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
|
|
108
|
+
const [mode, setMode] = useState<ViewMode>("paged");
|
|
109
|
+
const [dir, setDir] = useState<"rtl" | "ltr">(direction);
|
|
110
|
+
const [spread, setSpread] = useState(false);
|
|
111
|
+
const [current, setCurrent] = useState(() =>
|
|
112
|
+
Math.max(0, Math.min(pages.length - 1, initialPage - 1)),
|
|
113
|
+
);
|
|
114
|
+
const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
|
|
115
|
+
const { swiperRef, handleSwiper, handleSlideChange } = useSwiperSync(setCurrent);
|
|
116
|
+
|
|
117
|
+
const isLarge = useIsLargeScreen();
|
|
118
|
+
// Double-page seulement effective sur grand écran (lisibilité mobile).
|
|
119
|
+
const useSpread = spread && isLarge && mode === "paged";
|
|
120
|
+
|
|
121
|
+
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
|
|
122
|
+
|
|
123
|
+
// Défilement automatique fluide de la bande de vignettes vers la planche active
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (mode === "paged" && activeThumbRef.current) {
|
|
126
|
+
activeThumbRef.current.scrollIntoView({
|
|
127
|
+
behavior: "smooth",
|
|
128
|
+
block: "nearest",
|
|
129
|
+
inline: "center",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}, [current, mode]);
|
|
133
|
+
|
|
134
|
+
// Résolution des URL absolues une seule fois (les chemins sont stables).
|
|
135
|
+
const urls = useMemo(() => pages.map(resolveAssetUrl), [pages, resolveAssetUrl]);
|
|
136
|
+
const total = urls.length;
|
|
137
|
+
const baseId = useId();
|
|
138
|
+
|
|
139
|
+
// Préchargement intelligent des planches en arrière-plan
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (urls.length === 0) return;
|
|
142
|
+
// 1. Précharge la page courante, la précédente (retour arrière) et les 3 suivantes
|
|
143
|
+
const indices = [current, current - 1, current + 1, current + 2, current + 3];
|
|
144
|
+
for (const idx of indices) {
|
|
145
|
+
if (idx >= 0 && idx < urls.length) {
|
|
146
|
+
const img = new Image();
|
|
147
|
+
img.src = urls[idx];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Précharge les 3 premières pages du chapitre suivant en arrière-plan
|
|
152
|
+
if (nextPages && nextPages.length > 0) {
|
|
153
|
+
nextPages.slice(0, 3).forEach((page) => {
|
|
154
|
+
const img = new Image();
|
|
155
|
+
img.src = resolveAssetUrl(page);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 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)
|
|
160
|
+
if (prevPages && prevPages.length > 0) {
|
|
161
|
+
prevPages.slice(-3).forEach((page) => {
|
|
162
|
+
const img = new Image();
|
|
163
|
+
img.src = resolveAssetUrl(page);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}, [current, urls, prevPages, nextPages, resolveAssetUrl]);
|
|
167
|
+
|
|
168
|
+
// Repli automatique CDN <-> Bot en cas d'erreur réseau sur une planche
|
|
169
|
+
const handleImageError = useCallback(
|
|
170
|
+
(e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
171
|
+
const target = e.currentTarget;
|
|
172
|
+
if (target.dataset.fallbackApplied) return;
|
|
173
|
+
target.dataset.fallbackApplied = "true";
|
|
174
|
+
try {
|
|
175
|
+
const mirror = resolveMirrorUrl?.(target.src);
|
|
176
|
+
if (mirror) target.src = mirror;
|
|
177
|
+
} catch {
|
|
178
|
+
// URL invalide, aucun repli possible
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[resolveMirrorUrl],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
/* ------------------------------ Vertical ------------------------------ */
|
|
185
|
+
|
|
186
|
+
const scrollParentRef = useRef<HTMLDivElement>(null);
|
|
187
|
+
const virtualizer = useVirtualizer({
|
|
188
|
+
count: total,
|
|
189
|
+
getScrollElement: () => scrollParentRef.current,
|
|
190
|
+
// Estimation ~ hauteur viewport ; remplacée par la mesure réelle.
|
|
191
|
+
estimateSize: () => (typeof window === "undefined" ? 900 : window.innerHeight),
|
|
192
|
+
overscan: 3,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Synchronise le compteur en mode vertical sur la page la plus visible.
|
|
196
|
+
useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
onPageChange?.(current + 1);
|
|
200
|
+
}, [current, onPageChange]);
|
|
201
|
+
const apiPlanche = apiPageHref?.(current + 1) ?? null;
|
|
202
|
+
|
|
203
|
+
/* ------------------------------ Contrôles ----------------------------- */
|
|
204
|
+
|
|
205
|
+
const goPrevPage = useCallback(() => {
|
|
206
|
+
if (mode === "paged") swiperRef.current?.slidePrev();
|
|
207
|
+
else virtualizer.scrollToIndex(Math.max(0, current - 1), { align: "start" });
|
|
208
|
+
}, [mode, current, virtualizer]);
|
|
209
|
+
|
|
210
|
+
const goNextPage = useCallback(() => {
|
|
211
|
+
if (mode === "paged") swiperRef.current?.slideNext();
|
|
212
|
+
else
|
|
213
|
+
virtualizer.scrollToIndex(Math.min(total - 1, current + 1), {
|
|
214
|
+
align: "start",
|
|
215
|
+
});
|
|
216
|
+
}, [mode, current, total, virtualizer]);
|
|
217
|
+
|
|
218
|
+
const counter = total ? `${Math.min(current + 1, total)} / ${total}` : "0 / 0";
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
ref={containerRef}
|
|
223
|
+
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"
|
|
224
|
+
data-manga-reader
|
|
225
|
+
data-m3-component="MangaReader"
|
|
226
|
+
data-testid="manga-reader"
|
|
227
|
+
role="region"
|
|
228
|
+
aria-label={title}
|
|
229
|
+
>
|
|
230
|
+
{/* Barre de contrôles Toriyama HUD */}
|
|
231
|
+
<div className="m3-reader-hud z-10 flex flex-wrap items-center justify-between gap-2 px-3.5 py-2.5">
|
|
232
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
233
|
+
<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]">
|
|
234
|
+
{title}
|
|
235
|
+
</span>
|
|
236
|
+
<span className="m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black">
|
|
237
|
+
<span>{counter}</span>
|
|
238
|
+
</span>
|
|
239
|
+
{/* L'adresse de LA planche affichée, en un clic : la même que celle
|
|
240
|
+
que rendra `curl`. C'est le seul endroit où la correspondance
|
|
241
|
+
entre ce qu'on voit et ce qu'on peut récupérer est évidente. */}
|
|
242
|
+
{apiPlanche && (
|
|
243
|
+
<a
|
|
244
|
+
href={apiPlanche}
|
|
245
|
+
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"
|
|
246
|
+
title="Cette planche en JSON (image + transcription)"
|
|
247
|
+
>
|
|
248
|
+
API
|
|
249
|
+
</a>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="flex flex-wrap items-center gap-1.5 ml-auto">
|
|
254
|
+
{/* Toggle mode lecture */}
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
className={mode === "paged" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
|
258
|
+
onClick={() => setMode("paged")}
|
|
259
|
+
aria-pressed={mode === "paged"}
|
|
260
|
+
aria-label="Mode de lecture paginé"
|
|
261
|
+
title="Mode paginé"
|
|
262
|
+
>
|
|
263
|
+
<BookOpen size={13} aria-hidden /> Paginé
|
|
264
|
+
</button>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className={mode === "vertical" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
|
268
|
+
onClick={() => setMode("vertical")}
|
|
269
|
+
aria-pressed={mode === "vertical"}
|
|
270
|
+
aria-label="Mode de lecture vertical (webtoon)"
|
|
271
|
+
title="Mode vertical (webtoon)"
|
|
272
|
+
>
|
|
273
|
+
<ScrollText size={13} aria-hidden /> Défilement
|
|
274
|
+
</button>
|
|
275
|
+
|
|
276
|
+
{/* Toggle sens de lecture */}
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
className={dir === "rtl" ? "m3-reader-btn-active" : "m3-reader-btn"}
|
|
280
|
+
onClick={() => setDir((d) => (d === "rtl" ? "ltr" : "rtl"))}
|
|
281
|
+
aria-label={
|
|
282
|
+
dir === "rtl"
|
|
283
|
+
? "Sens de lecture manga (droite vers gauche)"
|
|
284
|
+
: "Sens de lecture BD (gauche vers droite)"
|
|
285
|
+
}
|
|
286
|
+
title={dir === "rtl" ? "Sens manga (droite→gauche)" : "Sens BD (gauche→droite)"}
|
|
287
|
+
>
|
|
288
|
+
<MoveHorizontal size={13} aria-hidden />
|
|
289
|
+
{dir === "rtl" ? "RTL" : "LTR"}
|
|
290
|
+
</button>
|
|
291
|
+
|
|
292
|
+
{/* Toggle double-page (paginé + grand écran) */}
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
className={useSpread ? "m3-reader-btn-active" : "m3-reader-btn"}
|
|
296
|
+
onClick={() => setSpread((s) => !s)}
|
|
297
|
+
disabled={mode !== "paged" || !isLarge}
|
|
298
|
+
aria-pressed={useSpread}
|
|
299
|
+
aria-label="Affichage double-page (grand écran)"
|
|
300
|
+
title="Double-page (grand écran)"
|
|
301
|
+
>
|
|
302
|
+
{useSpread ? <Columns2 size={13} aria-hidden /> : <Square size={13} aria-hidden />}
|
|
303
|
+
Double
|
|
304
|
+
</button>
|
|
305
|
+
|
|
306
|
+
{/* Plein écran */}
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
className="m3-reader-btn"
|
|
310
|
+
onClick={toggleFullscreen}
|
|
311
|
+
aria-label={isFullscreen ? "Quitter le plein écran" : "Activer le plein écran"}
|
|
312
|
+
title={isFullscreen ? "Quitter le plein écran" : "Plein écran"}
|
|
313
|
+
>
|
|
314
|
+
{isFullscreen ? (
|
|
315
|
+
<Minimize2 size={13} aria-hidden />
|
|
316
|
+
) : (
|
|
317
|
+
<Maximize2 size={13} aria-hidden />
|
|
318
|
+
)}
|
|
319
|
+
</button>
|
|
320
|
+
|
|
321
|
+
{/* Navigation chapitres */}
|
|
322
|
+
{prevHref ? (
|
|
323
|
+
renderChapterLink ? (
|
|
324
|
+
renderChapterLink({
|
|
325
|
+
href: prevHref,
|
|
326
|
+
children: (
|
|
327
|
+
<>
|
|
328
|
+
<ChevronLeft size={13} aria-hidden /> Préc.
|
|
329
|
+
</>
|
|
330
|
+
),
|
|
331
|
+
className: "m3-reader-btn",
|
|
332
|
+
ariaLabel: "Chapitre précédent",
|
|
333
|
+
})
|
|
334
|
+
) : (
|
|
335
|
+
<a
|
|
336
|
+
href={prevHref}
|
|
337
|
+
className="m3-reader-btn"
|
|
338
|
+
aria-label="Chapitre précédent"
|
|
339
|
+
title="Chapitre précédent"
|
|
340
|
+
>
|
|
341
|
+
<ChevronLeft size={13} aria-hidden /> Préc.
|
|
342
|
+
</a>
|
|
343
|
+
)
|
|
344
|
+
) : (
|
|
345
|
+
<span className="m3-reader-btn pointer-events-none opacity-40">
|
|
346
|
+
<ChevronLeft size={13} aria-hidden /> Préc.
|
|
347
|
+
</span>
|
|
348
|
+
)}
|
|
349
|
+
{nextHref ? (
|
|
350
|
+
renderChapterLink ? (
|
|
351
|
+
renderChapterLink({
|
|
352
|
+
href: nextHref,
|
|
353
|
+
children: (
|
|
354
|
+
<>
|
|
355
|
+
Suiv. <ChevronRight size={13} aria-hidden />
|
|
356
|
+
</>
|
|
357
|
+
),
|
|
358
|
+
className: "m3-reader-btn",
|
|
359
|
+
ariaLabel: "Chapitre suivant",
|
|
360
|
+
})
|
|
361
|
+
) : (
|
|
362
|
+
<a
|
|
363
|
+
href={nextHref}
|
|
364
|
+
className="m3-reader-btn"
|
|
365
|
+
aria-label="Chapitre suivant"
|
|
366
|
+
title="Chapitre suivant"
|
|
367
|
+
>
|
|
368
|
+
Suiv. <ChevronRight size={13} aria-hidden />
|
|
369
|
+
</a>
|
|
370
|
+
)
|
|
371
|
+
) : (
|
|
372
|
+
<span className="m3-reader-btn pointer-events-none opacity-40">
|
|
373
|
+
Suiv. <ChevronRight size={13} aria-hidden />
|
|
374
|
+
</span>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
{/* Zone de lecture */}
|
|
380
|
+
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[var(--reader-surface)]">
|
|
381
|
+
{total === 0 ? (
|
|
382
|
+
<div className="flex h-full items-center justify-center text-[18px] text-[var(--reader-on-surface)]">
|
|
383
|
+
Aucune planche disponible.
|
|
384
|
+
</div>
|
|
385
|
+
) : mode === "paged" ? (
|
|
386
|
+
<>
|
|
387
|
+
<Swiper
|
|
388
|
+
key={`${dir}-${useSpread ? "spread" : "single"}`}
|
|
389
|
+
// Le `key` force un remount à chaque changement de sens/double-page :
|
|
390
|
+
// on repart de la planche courante plutôt que de la page 1.
|
|
391
|
+
initialSlide={current}
|
|
392
|
+
dir={dir}
|
|
393
|
+
modules={[Keyboard, Zoom, Mousewheel]}
|
|
394
|
+
className="h-full w-full max-w-full"
|
|
395
|
+
slidesPerView={useSpread ? 2 : 1}
|
|
396
|
+
spaceBetween={useSpread ? 8 : 0}
|
|
397
|
+
keyboard={{ enabled: true }}
|
|
398
|
+
mousewheel={{ forceToAxis: true }}
|
|
399
|
+
zoom={{ maxRatio: 3, toggle: true }}
|
|
400
|
+
onSwiper={handleSwiper}
|
|
401
|
+
onSlideChange={handleSlideChange}
|
|
402
|
+
>
|
|
403
|
+
{urls.map((url, i) => (
|
|
404
|
+
<SwiperSlide key={url + i} zoom className="flex items-center justify-center">
|
|
405
|
+
{/* `swiper-zoom-container` requis par le module Zoom (pan/pinch). */}
|
|
406
|
+
<div className="swiper-zoom-container flex h-full w-full max-w-full items-center justify-center">
|
|
407
|
+
<img
|
|
408
|
+
src={url}
|
|
409
|
+
alt={`${title} — page ${i + 1}`}
|
|
410
|
+
loading={Math.abs(i - current) <= 1 ? "eager" : "lazy"}
|
|
411
|
+
draggable={false}
|
|
412
|
+
className="max-h-full max-w-full object-contain pointer-events-none select-none"
|
|
413
|
+
onError={handleImageError}
|
|
414
|
+
/>
|
|
415
|
+
</div>
|
|
416
|
+
</SwiperSlide>
|
|
417
|
+
))}
|
|
418
|
+
</Swiper>
|
|
419
|
+
|
|
420
|
+
{/* Flèches de navigation (respectent le sens RTL/LTR). */}
|
|
421
|
+
<button
|
|
422
|
+
type="button"
|
|
423
|
+
aria-label="Page précédente"
|
|
424
|
+
onClick={goPrevPage}
|
|
425
|
+
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)]"
|
|
426
|
+
>
|
|
427
|
+
{dir === "rtl" ? (
|
|
428
|
+
<ChevronRight size={22} aria-hidden />
|
|
429
|
+
) : (
|
|
430
|
+
<ChevronLeft size={22} aria-hidden />
|
|
431
|
+
)}
|
|
432
|
+
</button>
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
aria-label="Page suivante"
|
|
436
|
+
onClick={goNextPage}
|
|
437
|
+
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)]"
|
|
438
|
+
>
|
|
439
|
+
{dir === "rtl" ? (
|
|
440
|
+
<ChevronLeft size={22} aria-hidden />
|
|
441
|
+
) : (
|
|
442
|
+
<ChevronRight size={22} aria-hidden />
|
|
443
|
+
)}
|
|
444
|
+
</button>
|
|
445
|
+
</>
|
|
446
|
+
) : (
|
|
447
|
+
<div
|
|
448
|
+
ref={scrollParentRef}
|
|
449
|
+
// `tabIndex` requis pour rendre le conteneur défilable au clavier
|
|
450
|
+
// (flèches/PgUp/PgDn) — sinon la lecture verticale est inatteignable.
|
|
451
|
+
tabIndex={0}
|
|
452
|
+
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)]"
|
|
453
|
+
aria-label={`${title} — lecture verticale`}
|
|
454
|
+
>
|
|
455
|
+
<div
|
|
456
|
+
style={{
|
|
457
|
+
height: virtualizer.getTotalSize(),
|
|
458
|
+
width: "100%",
|
|
459
|
+
position: "relative",
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
{virtualizer.getVirtualItems().map((vi) => (
|
|
463
|
+
<div
|
|
464
|
+
key={`${baseId}-${vi.key}`}
|
|
465
|
+
data-index={vi.index}
|
|
466
|
+
ref={virtualizer.measureElement}
|
|
467
|
+
style={virtualRowStyle(vi.start)}
|
|
468
|
+
className="flex justify-center py-2"
|
|
469
|
+
>
|
|
470
|
+
<img
|
|
471
|
+
src={urls[vi.index]}
|
|
472
|
+
alt={`${title} — page ${vi.index + 1}`}
|
|
473
|
+
loading={Math.abs(vi.index - current) <= 1 ? "eager" : "lazy"}
|
|
474
|
+
draggable={false}
|
|
475
|
+
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)]"
|
|
476
|
+
onError={handleImageError}
|
|
477
|
+
/>
|
|
478
|
+
</div>
|
|
479
|
+
))}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
{/* Bande de vignettes (chapitres courts uniquement — cf. SHOW_STRIP_MAX). */}
|
|
486
|
+
{mode === "paged" && total > 0 && total <= SHOW_STRIP_MAX && (
|
|
487
|
+
<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">
|
|
488
|
+
<div className="flex gap-2.5 min-w-max mx-auto px-2">
|
|
489
|
+
{urls.map((url, idx) => (
|
|
490
|
+
<button
|
|
491
|
+
key={url + "-thumb-" + idx}
|
|
492
|
+
ref={current === idx ? activeThumbRef : undefined}
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={() => {
|
|
495
|
+
setCurrent(idx);
|
|
496
|
+
swiperRef.current?.slideTo(idx);
|
|
497
|
+
}}
|
|
498
|
+
className={`relative shrink-0 w-12 h-16 rounded-md overflow-hidden border-2 transition-all ${
|
|
499
|
+
current === idx
|
|
500
|
+
? "border-[var(--reader-accent)] scale-105 shadow-[0_0_12px_var(--reader-accent)]"
|
|
501
|
+
: "border-[var(--reader-outline)] shadow-[2px_2px_0px_#000] hover:border-white/50"
|
|
502
|
+
}`}
|
|
503
|
+
>
|
|
504
|
+
<img
|
|
505
|
+
src={url}
|
|
506
|
+
alt={`Page ${idx + 1}`}
|
|
507
|
+
className="w-full h-full object-cover select-none pointer-events-none"
|
|
508
|
+
loading="lazy"
|
|
509
|
+
/>
|
|
510
|
+
<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">
|
|
511
|
+
{idx + 1}
|
|
512
|
+
</span>
|
|
513
|
+
</button>
|
|
514
|
+
))}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
)}
|
|
518
|
+
|
|
519
|
+
{/* Tomes longs : la bande de vignettes serait trop lourde (jusqu'à ~282 nœuds)
|
|
520
|
+
→ saut direct à une planche par saisie du numéro. */}
|
|
521
|
+
{mode === "paged" && total > SHOW_STRIP_MAX && (
|
|
522
|
+
<form
|
|
523
|
+
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"
|
|
524
|
+
onSubmit={(e) => {
|
|
525
|
+
e.preventDefault();
|
|
526
|
+
const field = e.currentTarget.elements.namedItem("goto") as HTMLInputElement | null;
|
|
527
|
+
const val = Number(field?.value);
|
|
528
|
+
if (!Number.isFinite(val)) return;
|
|
529
|
+
const i = Math.min(Math.max(val - 1, 0), total - 1);
|
|
530
|
+
setCurrent(i);
|
|
531
|
+
swiperRef.current?.slideTo(i);
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
534
|
+
<label
|
|
535
|
+
htmlFor={`${baseId}-goto`}
|
|
536
|
+
className="text-[18px] font-black uppercase tracking-wider text-[var(--reader-on-surface)]"
|
|
537
|
+
>
|
|
538
|
+
Aller à la planche
|
|
539
|
+
</label>
|
|
540
|
+
<input
|
|
541
|
+
id={`${baseId}-goto`}
|
|
542
|
+
name="goto"
|
|
543
|
+
type="number"
|
|
544
|
+
min={1}
|
|
545
|
+
max={total}
|
|
546
|
+
key={current}
|
|
547
|
+
defaultValue={current + 1}
|
|
548
|
+
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)]"
|
|
549
|
+
/>
|
|
550
|
+
<span className="text-[18px] font-bold tabular-nums text-[var(--reader-on-surface)]/70">
|
|
551
|
+
/ {total}
|
|
552
|
+
</span>
|
|
553
|
+
<button type="submit" className="m3-reader-btn-active !py-1 !px-3.5">
|
|
554
|
+
OK
|
|
555
|
+
</button>
|
|
556
|
+
</form>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
export { MangaReader } from "./MangaReader";
|
|
3
|
+
export type { MangaReaderProps } from "./MangaReader";
|
|
4
|
+
export { DatabookReader } from "./DatabookReader";
|
|
5
|
+
export type { DatabookReaderPage, DatabookReaderProps, ReaderTranscription } from "./DatabookReader";
|
|
6
|
+
export { CollectionReader } from "./CollectionReader";
|
|
7
|
+
export type { CollectionReaderProps, ReaderCollectionChapter, ReaderCollectionEdition } from "./CollectionReader";
|