@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,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";