@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/dist/index.js ADDED
@@ -0,0 +1,1478 @@
1
+ 'use client';
2
+ // packages/m3-reader/src/MangaReader.tsx
3
+ import {
4
+ useCallback as useCallback2,
5
+ useEffect as useEffect2,
6
+ useId,
7
+ useMemo,
8
+ useRef as useRef2,
9
+ useState as useState2
10
+ } from "react";
11
+ import { useVirtualizer } from "@tanstack/react-virtual";
12
+ import {
13
+ BookOpen,
14
+ ChevronLeft,
15
+ ChevronRight,
16
+ Columns2,
17
+ Maximize2,
18
+ Minimize2,
19
+ MoveHorizontal,
20
+ ScrollText,
21
+ Square
22
+ } from "lucide-react";
23
+ import { Swiper, SwiperSlide } from "swiper/react";
24
+ import { Keyboard, Mousewheel, Zoom } from "swiper/modules";
25
+ import"swiper/css";
26
+ import"swiper/css/zoom";
27
+
28
+ // packages/m3-reader/src/use-reader-core.ts
29
+ import {
30
+ useCallback,
31
+ useEffect,
32
+ useRef,
33
+ useState
34
+ } from "react";
35
+ "use client";
36
+ var SHOW_STRIP_MAX = 40;
37
+ function useFullscreen(containerRef) {
38
+ const [isFullscreen, setIsFullscreen] = useState(false);
39
+ const [fullscreenError, setFullscreenError] = useState(null);
40
+ const mounted = useRef(false);
41
+ const operation = useRef(0);
42
+ const toggleFullscreen = useCallback(async () => {
43
+ const el = containerRef.current;
44
+ if (!el)
45
+ return false;
46
+ const doc = el.ownerDocument;
47
+ const current = ++operation.current;
48
+ if (mounted.current)
49
+ setFullscreenError(null);
50
+ try {
51
+ if (doc.fullscreenElement === el) {
52
+ if (typeof doc.exitFullscreen !== "function")
53
+ throw new Error("Fullscreen exit is unavailable");
54
+ await doc.exitFullscreen();
55
+ } else {
56
+ if (typeof el.requestFullscreen !== "function")
57
+ throw new Error("Fullscreen is unavailable");
58
+ await el.requestFullscreen();
59
+ }
60
+ if (mounted.current && current === operation.current) {
61
+ setIsFullscreen(Boolean(containerRef.current && doc.fullscreenElement === containerRef.current));
62
+ }
63
+ return true;
64
+ } catch (reason) {
65
+ if (mounted.current && current === operation.current) {
66
+ setFullscreenError(reason instanceof Error ? reason : new Error(String(reason)));
67
+ }
68
+ return false;
69
+ }
70
+ }, [containerRef]);
71
+ useEffect(() => {
72
+ const doc = containerRef.current?.ownerDocument;
73
+ if (!doc)
74
+ return;
75
+ mounted.current = true;
76
+ const onChange = () => setIsFullscreen(Boolean(containerRef.current && doc.fullscreenElement === containerRef.current));
77
+ onChange();
78
+ doc.addEventListener("fullscreenchange", onChange);
79
+ return () => {
80
+ mounted.current = false;
81
+ operation.current++;
82
+ doc.removeEventListener("fullscreenchange", onChange);
83
+ };
84
+ }, [containerRef]);
85
+ return { isFullscreen, fullscreenError, toggleFullscreen };
86
+ }
87
+ function useSwiperSync(setCurrent) {
88
+ const swiperRef = useRef(null);
89
+ const handleSwiper = useCallback((sw) => {
90
+ swiperRef.current = sw;
91
+ setCurrent(sw.realIndex ?? sw.activeIndex ?? 0);
92
+ }, [setCurrent]);
93
+ const handleSlideChange = useCallback((sw) => setCurrent(sw.realIndex ?? sw.activeIndex ?? 0), [setCurrent]);
94
+ return { swiperRef, handleSwiper, handleSlideChange };
95
+ }
96
+ function useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent) {
97
+ useEffect(() => {
98
+ if (mode !== "vertical")
99
+ return;
100
+ const el = scrollParentRef.current;
101
+ if (!el)
102
+ return;
103
+ const onScroll = () => {
104
+ const mid = el.scrollTop + el.clientHeight / 2;
105
+ const item = virtualizer.getVirtualItems().find((vi) => vi.start <= mid && vi.end >= mid);
106
+ if (item)
107
+ setCurrent(item.index);
108
+ };
109
+ el.addEventListener("scroll", onScroll, { passive: true });
110
+ onScroll();
111
+ return () => el.removeEventListener("scroll", onScroll);
112
+ }, [mode, scrollParentRef, virtualizer, setCurrent]);
113
+ }
114
+ var virtualRowStyle = (start) => ({
115
+ position: "absolute",
116
+ top: 0,
117
+ left: 0,
118
+ width: "100%",
119
+ transform: `translateY(${start}px)`
120
+ });
121
+
122
+ // packages/m3-reader/src/MangaReader.tsx
123
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
124
+ "use client";
125
+ var LG_BREAKPOINT = 1024;
126
+ function useIsLargeScreen() {
127
+ const [isLarge, setIsLarge] = useState2(false);
128
+ useEffect2(() => {
129
+ const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
130
+ const sync = () => setIsLarge(mql.matches);
131
+ sync();
132
+ mql.addEventListener("change", sync);
133
+ return () => mql.removeEventListener("change", sync);
134
+ }, []);
135
+ return isLarge;
136
+ }
137
+ function MangaReader({
138
+ pages,
139
+ title,
140
+ direction = "rtl",
141
+ prevHref = null,
142
+ nextHref = null,
143
+ prevPages = null,
144
+ nextPages = null,
145
+ initialPage = 1,
146
+ onPageChange,
147
+ apiPageHref,
148
+ resolveAssetUrl = (path) => path,
149
+ resolveMirrorUrl,
150
+ renderChapterLink
151
+ }) {
152
+ const containerRef = useRef2(null);
153
+ const [mode, setMode] = useState2("paged");
154
+ const [dir, setDir] = useState2(direction);
155
+ const [spread, setSpread] = useState2(false);
156
+ const [current, setCurrent] = useState2(() => Math.max(0, Math.min(pages.length - 1, initialPage - 1)));
157
+ const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
158
+ const { swiperRef, handleSwiper, handleSlideChange } = useSwiperSync(setCurrent);
159
+ const isLarge = useIsLargeScreen();
160
+ const useSpread = spread && isLarge && mode === "paged";
161
+ const activeThumbRef = useRef2(null);
162
+ useEffect2(() => {
163
+ if (mode === "paged" && activeThumbRef.current) {
164
+ activeThumbRef.current.scrollIntoView({
165
+ behavior: "smooth",
166
+ block: "nearest",
167
+ inline: "center"
168
+ });
169
+ }
170
+ }, [current, mode]);
171
+ const urls = useMemo(() => pages.map(resolveAssetUrl), [pages, resolveAssetUrl]);
172
+ const total = urls.length;
173
+ const baseId = useId();
174
+ useEffect2(() => {
175
+ if (urls.length === 0)
176
+ return;
177
+ const indices = [current, current - 1, current + 1, current + 2, current + 3];
178
+ for (const idx of indices) {
179
+ if (idx >= 0 && idx < urls.length) {
180
+ const img = new Image;
181
+ img.src = urls[idx];
182
+ }
183
+ }
184
+ if (nextPages && nextPages.length > 0) {
185
+ nextPages.slice(0, 3).forEach((page) => {
186
+ const img = new Image;
187
+ img.src = resolveAssetUrl(page);
188
+ });
189
+ }
190
+ if (prevPages && prevPages.length > 0) {
191
+ prevPages.slice(-3).forEach((page) => {
192
+ const img = new Image;
193
+ img.src = resolveAssetUrl(page);
194
+ });
195
+ }
196
+ }, [current, urls, prevPages, nextPages, resolveAssetUrl]);
197
+ const handleImageError = useCallback2((e) => {
198
+ const target = e.currentTarget;
199
+ if (target.dataset.fallbackApplied)
200
+ return;
201
+ target.dataset.fallbackApplied = "true";
202
+ try {
203
+ const mirror = resolveMirrorUrl?.(target.src);
204
+ if (mirror)
205
+ target.src = mirror;
206
+ } catch {}
207
+ }, [resolveMirrorUrl]);
208
+ const scrollParentRef = useRef2(null);
209
+ const virtualizer = useVirtualizer({
210
+ count: total,
211
+ getScrollElement: () => scrollParentRef.current,
212
+ estimateSize: () => typeof window === "undefined" ? 900 : window.innerHeight,
213
+ overscan: 3
214
+ });
215
+ useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent);
216
+ useEffect2(() => {
217
+ onPageChange?.(current + 1);
218
+ }, [current, onPageChange]);
219
+ const apiPlanche = apiPageHref?.(current + 1) ?? null;
220
+ const goPrevPage = useCallback2(() => {
221
+ if (mode === "paged")
222
+ swiperRef.current?.slidePrev();
223
+ else
224
+ virtualizer.scrollToIndex(Math.max(0, current - 1), { align: "start" });
225
+ }, [mode, current, virtualizer]);
226
+ const goNextPage = useCallback2(() => {
227
+ if (mode === "paged")
228
+ swiperRef.current?.slideNext();
229
+ else
230
+ virtualizer.scrollToIndex(Math.min(total - 1, current + 1), {
231
+ align: "start"
232
+ });
233
+ }, [mode, current, total, virtualizer]);
234
+ const counter = total ? `${Math.min(current + 1, total)} / ${total}` : "0 / 0";
235
+ return /* @__PURE__ */ jsxs("div", {
236
+ ref: containerRef,
237
+ 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",
238
+ "data-manga-reader": true,
239
+ "data-m3-component": "MangaReader",
240
+ "data-testid": "manga-reader",
241
+ role: "region",
242
+ "aria-label": title,
243
+ children: [
244
+ /* @__PURE__ */ jsxs("div", {
245
+ className: "m3-reader-hud z-10 flex flex-wrap items-center justify-between gap-2 px-3.5 py-2.5",
246
+ children: [
247
+ /* @__PURE__ */ jsxs("div", {
248
+ className: "flex items-center gap-2.5 min-w-0",
249
+ children: [
250
+ /* @__PURE__ */ jsx("span", {
251
+ 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]",
252
+ children: title
253
+ }),
254
+ /* @__PURE__ */ jsx("span", {
255
+ className: "m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black",
256
+ children: /* @__PURE__ */ jsx("span", {
257
+ children: counter
258
+ })
259
+ }),
260
+ apiPlanche && /* @__PURE__ */ jsx("a", {
261
+ href: apiPlanche,
262
+ 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",
263
+ title: "Cette planche en JSON (image + transcription)",
264
+ children: "API"
265
+ })
266
+ ]
267
+ }),
268
+ /* @__PURE__ */ jsxs("div", {
269
+ className: "flex flex-wrap items-center gap-1.5 ml-auto",
270
+ children: [
271
+ /* @__PURE__ */ jsxs("button", {
272
+ type: "button",
273
+ className: mode === "paged" ? "m3-reader-btn-active" : "m3-reader-btn",
274
+ onClick: () => setMode("paged"),
275
+ "aria-pressed": mode === "paged",
276
+ "aria-label": "Mode de lecture paginé",
277
+ title: "Mode paginé",
278
+ children: [
279
+ /* @__PURE__ */ jsx(BookOpen, {
280
+ size: 13,
281
+ "aria-hidden": true
282
+ }),
283
+ " Paginé"
284
+ ]
285
+ }),
286
+ /* @__PURE__ */ jsxs("button", {
287
+ type: "button",
288
+ className: mode === "vertical" ? "m3-reader-btn-active" : "m3-reader-btn",
289
+ onClick: () => setMode("vertical"),
290
+ "aria-pressed": mode === "vertical",
291
+ "aria-label": "Mode de lecture vertical (webtoon)",
292
+ title: "Mode vertical (webtoon)",
293
+ children: [
294
+ /* @__PURE__ */ jsx(ScrollText, {
295
+ size: 13,
296
+ "aria-hidden": true
297
+ }),
298
+ " Défilement"
299
+ ]
300
+ }),
301
+ /* @__PURE__ */ jsxs("button", {
302
+ type: "button",
303
+ className: dir === "rtl" ? "m3-reader-btn-active" : "m3-reader-btn",
304
+ onClick: () => setDir((d) => d === "rtl" ? "ltr" : "rtl"),
305
+ "aria-label": dir === "rtl" ? "Sens de lecture manga (droite vers gauche)" : "Sens de lecture BD (gauche vers droite)",
306
+ title: dir === "rtl" ? "Sens manga (droite→gauche)" : "Sens BD (gauche→droite)",
307
+ children: [
308
+ /* @__PURE__ */ jsx(MoveHorizontal, {
309
+ size: 13,
310
+ "aria-hidden": true
311
+ }),
312
+ dir === "rtl" ? "RTL" : "LTR"
313
+ ]
314
+ }),
315
+ /* @__PURE__ */ jsxs("button", {
316
+ type: "button",
317
+ className: useSpread ? "m3-reader-btn-active" : "m3-reader-btn",
318
+ onClick: () => setSpread((s) => !s),
319
+ disabled: mode !== "paged" || !isLarge,
320
+ "aria-pressed": useSpread,
321
+ "aria-label": "Affichage double-page (grand écran)",
322
+ title: "Double-page (grand écran)",
323
+ children: [
324
+ useSpread ? /* @__PURE__ */ jsx(Columns2, {
325
+ size: 13,
326
+ "aria-hidden": true
327
+ }) : /* @__PURE__ */ jsx(Square, {
328
+ size: 13,
329
+ "aria-hidden": true
330
+ }),
331
+ "Double"
332
+ ]
333
+ }),
334
+ /* @__PURE__ */ jsx("button", {
335
+ type: "button",
336
+ className: "m3-reader-btn",
337
+ onClick: toggleFullscreen,
338
+ "aria-label": isFullscreen ? "Quitter le plein écran" : "Activer le plein écran",
339
+ title: isFullscreen ? "Quitter le plein écran" : "Plein écran",
340
+ children: isFullscreen ? /* @__PURE__ */ jsx(Minimize2, {
341
+ size: 13,
342
+ "aria-hidden": true
343
+ }) : /* @__PURE__ */ jsx(Maximize2, {
344
+ size: 13,
345
+ "aria-hidden": true
346
+ })
347
+ }),
348
+ prevHref ? renderChapterLink ? renderChapterLink({
349
+ href: prevHref,
350
+ children: /* @__PURE__ */ jsxs(Fragment, {
351
+ children: [
352
+ /* @__PURE__ */ jsx(ChevronLeft, {
353
+ size: 13,
354
+ "aria-hidden": true
355
+ }),
356
+ " Préc."
357
+ ]
358
+ }),
359
+ className: "m3-reader-btn",
360
+ ariaLabel: "Chapitre précédent"
361
+ }) : /* @__PURE__ */ jsxs("a", {
362
+ href: prevHref,
363
+ className: "m3-reader-btn",
364
+ "aria-label": "Chapitre précédent",
365
+ title: "Chapitre précédent",
366
+ children: [
367
+ /* @__PURE__ */ jsx(ChevronLeft, {
368
+ size: 13,
369
+ "aria-hidden": true
370
+ }),
371
+ " Préc."
372
+ ]
373
+ }) : /* @__PURE__ */ jsxs("span", {
374
+ className: "m3-reader-btn pointer-events-none opacity-40",
375
+ children: [
376
+ /* @__PURE__ */ jsx(ChevronLeft, {
377
+ size: 13,
378
+ "aria-hidden": true
379
+ }),
380
+ " Préc."
381
+ ]
382
+ }),
383
+ nextHref ? renderChapterLink ? renderChapterLink({
384
+ href: nextHref,
385
+ children: /* @__PURE__ */ jsxs(Fragment, {
386
+ children: [
387
+ "Suiv. ",
388
+ /* @__PURE__ */ jsx(ChevronRight, {
389
+ size: 13,
390
+ "aria-hidden": true
391
+ })
392
+ ]
393
+ }),
394
+ className: "m3-reader-btn",
395
+ ariaLabel: "Chapitre suivant"
396
+ }) : /* @__PURE__ */ jsxs("a", {
397
+ href: nextHref,
398
+ className: "m3-reader-btn",
399
+ "aria-label": "Chapitre suivant",
400
+ title: "Chapitre suivant",
401
+ children: [
402
+ "Suiv. ",
403
+ /* @__PURE__ */ jsx(ChevronRight, {
404
+ size: 13,
405
+ "aria-hidden": true
406
+ })
407
+ ]
408
+ }) : /* @__PURE__ */ jsxs("span", {
409
+ className: "m3-reader-btn pointer-events-none opacity-40",
410
+ children: [
411
+ "Suiv. ",
412
+ /* @__PURE__ */ jsx(ChevronRight, {
413
+ size: 13,
414
+ "aria-hidden": true
415
+ })
416
+ ]
417
+ })
418
+ ]
419
+ })
420
+ ]
421
+ }),
422
+ /* @__PURE__ */ jsx("div", {
423
+ className: "relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[var(--reader-surface)]",
424
+ children: total === 0 ? /* @__PURE__ */ jsx("div", {
425
+ className: "flex h-full items-center justify-center text-[18px] text-[var(--reader-on-surface)]",
426
+ children: "Aucune planche disponible."
427
+ }) : mode === "paged" ? /* @__PURE__ */ jsxs(Fragment, {
428
+ children: [
429
+ /* @__PURE__ */ jsx(Swiper, {
430
+ initialSlide: current,
431
+ dir,
432
+ modules: [Keyboard, Zoom, Mousewheel],
433
+ className: "h-full w-full max-w-full",
434
+ slidesPerView: useSpread ? 2 : 1,
435
+ spaceBetween: useSpread ? 8 : 0,
436
+ keyboard: { enabled: true },
437
+ mousewheel: { forceToAxis: true },
438
+ zoom: { maxRatio: 3, toggle: true },
439
+ onSwiper: handleSwiper,
440
+ onSlideChange: handleSlideChange,
441
+ children: urls.map((url, i) => /* @__PURE__ */ jsx(SwiperSlide, {
442
+ zoom: true,
443
+ className: "flex items-center justify-center",
444
+ children: /* @__PURE__ */ jsx("div", {
445
+ className: "swiper-zoom-container flex h-full w-full max-w-full items-center justify-center",
446
+ children: /* @__PURE__ */ jsx("img", {
447
+ src: url,
448
+ alt: `${title} — page ${i + 1}`,
449
+ loading: Math.abs(i - current) <= 1 ? "eager" : "lazy",
450
+ draggable: false,
451
+ className: "max-h-full max-w-full object-contain pointer-events-none select-none",
452
+ onError: handleImageError
453
+ })
454
+ })
455
+ }, url + i))
456
+ }, `${dir}-${useSpread ? "spread" : "single"}`),
457
+ /* @__PURE__ */ jsx("button", {
458
+ type: "button",
459
+ "aria-label": "Page précédente",
460
+ onClick: goPrevPage,
461
+ 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)]",
462
+ children: dir === "rtl" ? /* @__PURE__ */ jsx(ChevronRight, {
463
+ size: 22,
464
+ "aria-hidden": true
465
+ }) : /* @__PURE__ */ jsx(ChevronLeft, {
466
+ size: 22,
467
+ "aria-hidden": true
468
+ })
469
+ }),
470
+ /* @__PURE__ */ jsx("button", {
471
+ type: "button",
472
+ "aria-label": "Page suivante",
473
+ onClick: goNextPage,
474
+ 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)]",
475
+ children: dir === "rtl" ? /* @__PURE__ */ jsx(ChevronLeft, {
476
+ size: 22,
477
+ "aria-hidden": true
478
+ }) : /* @__PURE__ */ jsx(ChevronRight, {
479
+ size: 22,
480
+ "aria-hidden": true
481
+ })
482
+ })
483
+ ]
484
+ }) : /* @__PURE__ */ jsx("div", {
485
+ ref: scrollParentRef,
486
+ tabIndex: 0,
487
+ 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)]",
488
+ "aria-label": `${title} — lecture verticale`,
489
+ children: /* @__PURE__ */ jsx("div", {
490
+ style: {
491
+ height: virtualizer.getTotalSize(),
492
+ width: "100%",
493
+ position: "relative"
494
+ },
495
+ children: virtualizer.getVirtualItems().map((vi) => /* @__PURE__ */ jsx("div", {
496
+ "data-index": vi.index,
497
+ ref: virtualizer.measureElement,
498
+ style: virtualRowStyle(vi.start),
499
+ className: "flex justify-center py-2",
500
+ children: /* @__PURE__ */ jsx("img", {
501
+ src: urls[vi.index],
502
+ alt: `${title} — page ${vi.index + 1}`,
503
+ loading: Math.abs(vi.index - current) <= 1 ? "eager" : "lazy",
504
+ draggable: false,
505
+ 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)]",
506
+ onError: handleImageError
507
+ })
508
+ }, `${baseId}-${vi.key}`))
509
+ })
510
+ })
511
+ }),
512
+ mode === "paged" && total > 0 && total <= SHOW_STRIP_MAX && /* @__PURE__ */ jsx("div", {
513
+ 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",
514
+ children: /* @__PURE__ */ jsx("div", {
515
+ className: "flex gap-2.5 min-w-max mx-auto px-2",
516
+ children: urls.map((url, idx) => /* @__PURE__ */ jsxs("button", {
517
+ ref: current === idx ? activeThumbRef : undefined,
518
+ type: "button",
519
+ onClick: () => {
520
+ setCurrent(idx);
521
+ swiperRef.current?.slideTo(idx);
522
+ },
523
+ className: `relative shrink-0 w-12 h-16 rounded-md overflow-hidden border-2 transition-all ${current === idx ? "border-[var(--reader-accent)] scale-105 shadow-[0_0_12px_var(--reader-accent)]" : "border-[var(--reader-outline)] shadow-[2px_2px_0px_#000] hover:border-white/50"}`,
524
+ children: [
525
+ /* @__PURE__ */ jsx("img", {
526
+ src: url,
527
+ alt: `Page ${idx + 1}`,
528
+ className: "w-full h-full object-cover select-none pointer-events-none",
529
+ loading: "lazy"
530
+ }),
531
+ /* @__PURE__ */ jsx("span", {
532
+ 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",
533
+ children: idx + 1
534
+ })
535
+ ]
536
+ }, url + "-thumb-" + idx))
537
+ })
538
+ }),
539
+ mode === "paged" && total > SHOW_STRIP_MAX && /* @__PURE__ */ jsxs("form", {
540
+ 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",
541
+ onSubmit: (e) => {
542
+ e.preventDefault();
543
+ const field = e.currentTarget.elements.namedItem("goto");
544
+ const val = Number(field?.value);
545
+ if (!Number.isFinite(val))
546
+ return;
547
+ const i = Math.min(Math.max(val - 1, 0), total - 1);
548
+ setCurrent(i);
549
+ swiperRef.current?.slideTo(i);
550
+ },
551
+ children: [
552
+ /* @__PURE__ */ jsx("label", {
553
+ htmlFor: `${baseId}-goto`,
554
+ className: "text-[18px] font-black uppercase tracking-wider text-[var(--reader-on-surface)]",
555
+ children: "Aller à la planche"
556
+ }),
557
+ /* @__PURE__ */ jsx("input", {
558
+ id: `${baseId}-goto`,
559
+ name: "goto",
560
+ type: "number",
561
+ min: 1,
562
+ max: total,
563
+ defaultValue: current + 1,
564
+ 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)]"
565
+ }, current),
566
+ /* @__PURE__ */ jsxs("span", {
567
+ className: "text-[18px] font-bold tabular-nums text-[var(--reader-on-surface)]/70",
568
+ children: [
569
+ "/ ",
570
+ total
571
+ ]
572
+ }),
573
+ /* @__PURE__ */ jsx("button", {
574
+ type: "submit",
575
+ className: "m3-reader-btn-active !py-1 !px-3.5",
576
+ children: "OK"
577
+ })
578
+ ]
579
+ })
580
+ ]
581
+ });
582
+ }
583
+ // packages/m3-reader/src/DatabookReader.tsx
584
+ import { useCallback as useCallback3, useEffect as useEffect4, useId as useId2, useMemo as useMemo2, useRef as useRef4, useState as useState3 } from "react";
585
+ import { useVirtualizer as useVirtualizer2 } from "@tanstack/react-virtual";
586
+
587
+ // packages/m3-reader/src/use-focus-trap.ts
588
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
589
+ "use client";
590
+ var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
591
+ var activeTraps = new WeakMap;
592
+ function isTabbable(element) {
593
+ if (element.tabIndex < 0 || element.matches(":disabled, input[type='hidden']"))
594
+ return false;
595
+ if (element.closest("[hidden], [inert]"))
596
+ return false;
597
+ const view = element.ownerDocument.defaultView;
598
+ const visibility = view?.getComputedStyle(element).visibility;
599
+ if (visibility === "hidden" || visibility === "collapse")
600
+ return false;
601
+ for (let ancestor = element;ancestor; ancestor = ancestor.parentElement) {
602
+ if (view?.getComputedStyle(ancestor).display === "none")
603
+ return false;
604
+ }
605
+ return true;
606
+ }
607
+ function useFocusTrap(panelRef, active, onEscape) {
608
+ const onEscapeRef = useRef3(onEscape);
609
+ onEscapeRef.current = onEscape;
610
+ useEffect3(() => {
611
+ if (!active)
612
+ return;
613
+ const panel = panelRef.current;
614
+ if (!panel)
615
+ return;
616
+ const doc = panel.ownerDocument;
617
+ let state = activeTraps.get(doc);
618
+ if (!state) {
619
+ state = { stack: [], previousOverflow: doc.body.style.overflow };
620
+ activeTraps.set(doc, state);
621
+ }
622
+ const lease = {
623
+ panel,
624
+ previousFocused: doc.activeElement,
625
+ previousTrap: state.stack.at(-1)
626
+ };
627
+ state.stack.push(lease);
628
+ panelRef.current?.focus();
629
+ const onKey = (e) => {
630
+ if (state.stack.at(-1) !== lease)
631
+ return;
632
+ if (e.key === "Escape") {
633
+ onEscapeRef.current?.();
634
+ return;
635
+ }
636
+ if (e.key !== "Tab")
637
+ return;
638
+ const currentPanel = panelRef.current;
639
+ if (!currentPanel)
640
+ return;
641
+ const items = [...currentPanel.querySelectorAll(FOCUSABLE)].filter(isTabbable);
642
+ if (items.length === 0) {
643
+ e.preventDefault();
644
+ currentPanel.focus();
645
+ return;
646
+ }
647
+ const first = items[0];
648
+ const last = items[items.length - 1];
649
+ const current = doc.activeElement;
650
+ if (!current || !items.includes(current)) {
651
+ e.preventDefault();
652
+ (e.shiftKey ? last : first).focus();
653
+ } else if (e.shiftKey && current === first) {
654
+ e.preventDefault();
655
+ last.focus();
656
+ } else if (!e.shiftKey && current === last) {
657
+ e.preventDefault();
658
+ first.focus();
659
+ }
660
+ };
661
+ doc.addEventListener("keydown", onKey);
662
+ doc.body.style.overflow = "hidden";
663
+ return () => {
664
+ doc.removeEventListener("keydown", onKey);
665
+ const wasTop = state.stack.at(-1) === lease;
666
+ state.stack.splice(state.stack.indexOf(lease), 1);
667
+ for (const remaining of state.stack) {
668
+ if (remaining.previousTrap === lease) {
669
+ remaining.previousTrap = lease.previousTrap;
670
+ remaining.previousFocused = lease.previousFocused;
671
+ }
672
+ }
673
+ const top = state.stack.at(-1);
674
+ if (!top) {
675
+ doc.body.style.overflow = state.previousOverflow;
676
+ activeTraps.delete(doc);
677
+ }
678
+ if (wasTop) {
679
+ const previous = lease.previousFocused;
680
+ if (previous?.isConnected && (!top || top.panel.contains(previous)))
681
+ previous.focus();
682
+ else
683
+ top?.panel.focus();
684
+ }
685
+ };
686
+ }, [panelRef, active]);
687
+ }
688
+
689
+ // packages/m3-reader/src/DatabookReader.tsx
690
+ import {
691
+ BookOpen as BookOpen2,
692
+ ChevronLeft as ChevronLeft2,
693
+ ChevronRight as ChevronRight2,
694
+ Maximize2 as Maximize22,
695
+ Minimize2 as Minimize22,
696
+ ScrollText as ScrollText2,
697
+ X,
698
+ ZoomIn
699
+ } from "lucide-react";
700
+ import { Swiper as Swiper2, SwiperSlide as SwiperSlide2 } from "swiper/react";
701
+ import { Mousewheel as Mousewheel2, Virtual, Zoom as Zoom2 } from "swiper/modules";
702
+ import"swiper/css";
703
+ import"swiper/css/zoom";
704
+ import"swiper/css/virtual";
705
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
706
+ "use client";
707
+ function pageNumber(p, fallback) {
708
+ const raw = p.number;
709
+ if (typeof raw === "number" && Number.isFinite(raw))
710
+ return Math.trunc(raw);
711
+ if (typeof raw === "string" && String(raw).trim() && Number.isFinite(Number(raw))) {
712
+ return Math.trunc(Number(raw));
713
+ }
714
+ return fallback;
715
+ }
716
+ function resolvePages(pages, resolveAssetUrl) {
717
+ const out = [];
718
+ for (let i = 0;i < pages.length; i++) {
719
+ const p = pages[i];
720
+ const img = typeof p.image === "string" && p.image.trim() ? p.image.trim() : null;
721
+ const text = typeof p.text === "string" && p.text.trim() ? p.text.trim() : null;
722
+ const aDuTexte = p.aDuTexte === true || text !== null;
723
+ if (!img && !aDuTexte)
724
+ continue;
725
+ out.push({
726
+ number: pageNumber(p, i + 1),
727
+ imageUrl: img ? resolveAssetUrl(img) : null,
728
+ text,
729
+ aDuTexte,
730
+ verifiee: p.verifiee === true
731
+ });
732
+ }
733
+ return out;
734
+ }
735
+ var FENETRE_TEXTE = 6;
736
+ function useTextesPlanches(loadTranscriptions, items, current) {
737
+ const [charges, setCharges] = useState3(() => new Map);
738
+ const demandes = useRef4(new Set);
739
+ const manquants = useMemo2(() => {
740
+ if (!loadTranscriptions)
741
+ return [];
742
+ const de = Math.max(0, current - FENETRE_TEXTE);
743
+ const a = Math.min(items.length, current + FENETRE_TEXTE + 1);
744
+ const out = [];
745
+ for (let i = de;i < a; i++) {
746
+ const it = items[i];
747
+ if (!it || !it.aDuTexte || it.text !== null)
748
+ continue;
749
+ if (charges.has(it.number) || demandes.current.has(it.number))
750
+ continue;
751
+ out.push(it.number);
752
+ }
753
+ return out;
754
+ }, [loadTranscriptions, items, current, charges]);
755
+ useEffect4(() => {
756
+ if (manquants.length === 0)
757
+ return;
758
+ for (const n of manquants)
759
+ demandes.current.add(n);
760
+ const stop = new AbortController;
761
+ loadTranscriptions(manquants, stop.signal).then((textes) => ({ textes })).then((d) => {
762
+ const recus = Object.entries(d.textes ?? {});
763
+ if (recus.length === 0)
764
+ return;
765
+ setCharges((prec) => {
766
+ const suiv = new Map(prec);
767
+ for (const [n, t] of recus)
768
+ suiv.set(Number(n), t);
769
+ return suiv;
770
+ });
771
+ return;
772
+ }).catch(() => {});
773
+ return () => stop.abort();
774
+ }, [manquants, loadTranscriptions]);
775
+ return charges;
776
+ }
777
+ function DatabookReader({
778
+ pages,
779
+ title,
780
+ resolveAssetUrl = (path) => path,
781
+ imageSrc = (url) => url,
782
+ imageSrcSet = () => null,
783
+ loadTranscriptions,
784
+ renderTranscription,
785
+ preloadCount = 4
786
+ }) {
787
+ const containerRef = useRef4(null);
788
+ const lightboxRef = useRef4(null);
789
+ const baseId = useId2();
790
+ const items = useMemo2(() => resolvePages(pages, resolveAssetUrl), [pages, resolveAssetUrl]);
791
+ const total = items.length;
792
+ const [mode, setMode] = useState3("vertical");
793
+ const [current, setCurrent] = useState3(0);
794
+ const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
795
+ const { swiperRef, handleSwiper, handleSlideChange } = useSwiperSync(setCurrent);
796
+ const [lightbox, setLightbox] = useState3(false);
797
+ const textesCharges = useTextesPlanches(loadTranscriptions, items, current);
798
+ const texteDe = useCallback3((p) => p ? p.text ?? textesCharges.get(p.number) ?? null : null, [textesCharges]);
799
+ useFocusTrap(lightboxRef, lightbox, () => setLightbox(false));
800
+ useEffect4(() => {
801
+ if (current >= total && total > 0)
802
+ setCurrent(total - 1);
803
+ if (total === 0)
804
+ setCurrent(0);
805
+ }, [total, current]);
806
+ useEffect4(() => {
807
+ if (total === 0)
808
+ return;
809
+ for (const idx of [current - 1, current, current + 1, current + 2]) {
810
+ const url = items[idx]?.imageUrl;
811
+ if (!url)
812
+ continue;
813
+ const img = new Image;
814
+ img.src = url;
815
+ }
816
+ }, [current, items, total]);
817
+ const [fullscreenDispo, setFullscreenDispo] = useState3(false);
818
+ useEffect4(() => {
819
+ setFullscreenDispo(typeof document !== "undefined" && document.fullscreenEnabled && typeof containerRef.current?.requestFullscreen === "function");
820
+ }, []);
821
+ const scrollParentRef = useRef4(null);
822
+ const virtualizer = useVirtualizer2({
823
+ count: total,
824
+ getScrollElement: () => scrollParentRef.current,
825
+ estimateSize: () => typeof window === "undefined" ? 1100 : Math.round(window.innerHeight * 0.95),
826
+ overscan: Math.max(2, preloadCount)
827
+ });
828
+ const goTo = useCallback3((idx) => {
829
+ const i = Math.min(Math.max(idx, 0), Math.max(0, total - 1));
830
+ setCurrent(i);
831
+ if (mode === "paged")
832
+ swiperRef.current?.slideTo(i);
833
+ else
834
+ virtualizer.scrollToIndex(i, { align: "start" });
835
+ }, [mode, total, virtualizer]);
836
+ useVerticalScrollSync(mode, scrollParentRef, virtualizer, setCurrent);
837
+ useEffect4(() => {
838
+ const actif = lightbox || mode === "paged";
839
+ if (!actif)
840
+ return;
841
+ const onKey = (e) => {
842
+ const cible = e.target;
843
+ const tag = cible?.tagName;
844
+ if (tag === "INPUT" || tag === "TEXTAREA" || cible?.isContentEditable)
845
+ return;
846
+ switch (e.key) {
847
+ case "Escape":
848
+ if (lightbox)
849
+ setLightbox(false);
850
+ return;
851
+ case "ArrowLeft":
852
+ e.preventDefault();
853
+ goTo(current - 1);
854
+ return;
855
+ case "ArrowRight":
856
+ e.preventDefault();
857
+ goTo(current + 1);
858
+ return;
859
+ case "Home":
860
+ e.preventDefault();
861
+ goTo(0);
862
+ return;
863
+ case "End":
864
+ e.preventDefault();
865
+ goTo(total - 1);
866
+ return;
867
+ }
868
+ };
869
+ window.addEventListener("keydown", onKey);
870
+ return () => window.removeEventListener("keydown", onKey);
871
+ }, [lightbox, mode, total, current, goTo]);
872
+ const goPrevPage = useCallback3(() => {
873
+ goTo(current - 1);
874
+ }, [current, goTo]);
875
+ const goNextPage = useCallback3(() => {
876
+ goTo(current + 1);
877
+ }, [current, goTo]);
878
+ const openLightbox = useCallback3((idx) => {
879
+ if (typeof idx === "number")
880
+ setCurrent(idx);
881
+ const item = items[typeof idx === "number" ? idx : current];
882
+ if (item?.imageUrl)
883
+ setLightbox(true);
884
+ }, [items, current]);
885
+ const toucheRef = useRef4(null);
886
+ const onTouchStart = useCallback3((e) => {
887
+ const t = e.touches[0];
888
+ toucheRef.current = t ? { x: t.clientX, y: t.clientY } : null;
889
+ }, []);
890
+ const onTouchEnd = useCallback3((e) => {
891
+ const depart = toucheRef.current;
892
+ toucheRef.current = null;
893
+ const t = e.changedTouches[0];
894
+ if (!depart || !t)
895
+ return;
896
+ const dx = t.clientX - depart.x;
897
+ const dy = t.clientY - depart.y;
898
+ if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy))
899
+ return;
900
+ goTo(dx < 0 ? current + 1 : current - 1);
901
+ }, [current, goTo]);
902
+ const currentItem = items[current];
903
+ const currentNum = currentItem?.number ?? current + 1;
904
+ const counter = total ? `n°${currentNum} · ${Math.min(current + 1, total)}/${total}` : "0 / 0";
905
+ const currentText = texteDe(currentItem);
906
+ const currentImage = currentItem?.imageUrl ?? null;
907
+ if (total === 0) {
908
+ return /* @__PURE__ */ jsx2("div", {
909
+ className: "m3-reader-frame flex min-h-[200px] items-center justify-center rounded-xl p-8 text-[18px] text-[var(--reader-on-surface)]/50",
910
+ children: "Aucune page à afficher pour le moment."
911
+ });
912
+ }
913
+ const legendBlock = /* @__PURE__ */ jsxs2("div", {
914
+ 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)]",
915
+ children: [
916
+ /* @__PURE__ */ jsxs2("div", {
917
+ className: "mb-2 shrink-0 flex items-center justify-between",
918
+ children: [
919
+ /* @__PURE__ */ jsx2("span", {
920
+ className: "m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black",
921
+ children: /* @__PURE__ */ jsxs2("span", {
922
+ children: [
923
+ "PLANCHE ",
924
+ currentNum
925
+ ]
926
+ })
927
+ }),
928
+ /* @__PURE__ */ jsxs2("span", {
929
+ className: "text-[18px] font-bold tabular-nums text-[var(--reader-on-surface)]/60",
930
+ children: [
931
+ current + 1,
932
+ " / ",
933
+ total
934
+ ]
935
+ })
936
+ ]
937
+ }),
938
+ /* @__PURE__ */ jsx2("div", {
939
+ className: "min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1 m3-reader-scroll",
940
+ children: currentText ? renderTranscription?.({
941
+ text: currentText,
942
+ page: currentNum,
943
+ verified: currentItem?.verifiee ?? false
944
+ }) ?? currentText : currentItem?.aDuTexte ? /* @__PURE__ */ jsx2("p", {
945
+ className: "text-[18px] italic text-[var(--reader-on-surface)]/50",
946
+ children: "Chargement de la transcription…"
947
+ }) : /* @__PURE__ */ jsx2("p", {
948
+ className: "text-[18px] italic text-[var(--reader-on-surface)]/50",
949
+ children: "Aucune description pour cette page."
950
+ })
951
+ })
952
+ ]
953
+ });
954
+ return /* @__PURE__ */ jsxs2("div", {
955
+ ref: containerRef,
956
+ 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",
957
+ "data-databook-reader": true,
958
+ "data-m3-component": "DatabookReader",
959
+ "data-testid": "databook-reader",
960
+ role: "region",
961
+ "aria-label": title,
962
+ children: [
963
+ /* @__PURE__ */ jsxs2("div", {
964
+ className: "m3-reader-hud z-10 flex flex-nowrap items-center gap-2 px-3 py-2.5 sm:flex-wrap",
965
+ children: [
966
+ /* @__PURE__ */ jsx2("span", {
967
+ 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",
968
+ children: title
969
+ }),
970
+ /* @__PURE__ */ jsx2("span", {
971
+ 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",
972
+ children: /* @__PURE__ */ jsx2("span", {
973
+ children: counter
974
+ })
975
+ }),
976
+ /* @__PURE__ */ jsxs2("div", {
977
+ className: "ml-auto flex shrink-0 items-center gap-1.5 sm:ml-0 sm:gap-2",
978
+ children: [
979
+ /* @__PURE__ */ jsxs2("button", {
980
+ type: "button",
981
+ className: mode === "vertical" ? "m3-reader-btn-active" : "m3-reader-btn",
982
+ onClick: () => setMode("vertical"),
983
+ "aria-pressed": mode === "vertical",
984
+ "aria-label": "Mode vertical (planche + texte)",
985
+ title: "Mode vertical (planche + texte)",
986
+ children: [
987
+ /* @__PURE__ */ jsx2(ScrollText2, {
988
+ size: 14,
989
+ "aria-hidden": true
990
+ }),
991
+ /* @__PURE__ */ jsx2("span", {
992
+ className: "hidden sm:inline",
993
+ children: "Défilement"
994
+ })
995
+ ]
996
+ }),
997
+ /* @__PURE__ */ jsxs2("button", {
998
+ type: "button",
999
+ className: mode === "paged" ? "m3-reader-btn-active" : "m3-reader-btn",
1000
+ onClick: () => setMode("paged"),
1001
+ "aria-pressed": mode === "paged",
1002
+ "aria-label": "Mode paginé",
1003
+ title: "Mode paginé",
1004
+ children: [
1005
+ /* @__PURE__ */ jsx2(BookOpen2, {
1006
+ size: 14,
1007
+ "aria-hidden": true
1008
+ }),
1009
+ /* @__PURE__ */ jsx2("span", {
1010
+ className: "hidden sm:inline",
1011
+ children: "Paginé"
1012
+ })
1013
+ ]
1014
+ }),
1015
+ /* @__PURE__ */ jsx2("button", {
1016
+ type: "button",
1017
+ className: "m3-reader-btn",
1018
+ onClick: () => openLightbox(),
1019
+ disabled: !currentImage,
1020
+ "aria-label": "Agrandir la planche",
1021
+ title: "Agrandir la planche",
1022
+ children: /* @__PURE__ */ jsx2(ZoomIn, {
1023
+ size: 14,
1024
+ "aria-hidden": true
1025
+ })
1026
+ }),
1027
+ fullscreenDispo && /* @__PURE__ */ jsx2("button", {
1028
+ type: "button",
1029
+ className: "m3-reader-btn",
1030
+ onClick: toggleFullscreen,
1031
+ "aria-label": isFullscreen ? "Quitter le plein écran" : "Activer le plein écran",
1032
+ title: isFullscreen ? "Quitter le plein écran" : "Plein écran",
1033
+ children: isFullscreen ? /* @__PURE__ */ jsx2(Minimize22, {
1034
+ size: 14,
1035
+ "aria-hidden": true
1036
+ }) : /* @__PURE__ */ jsx2(Maximize22, {
1037
+ size: 14,
1038
+ "aria-hidden": true
1039
+ })
1040
+ })
1041
+ ]
1042
+ })
1043
+ ]
1044
+ }),
1045
+ /* @__PURE__ */ jsx2("div", {
1046
+ className: "relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[var(--reader-surface)]",
1047
+ children: mode === "paged" ? /* @__PURE__ */ jsxs2("div", {
1048
+ className: "flex h-full min-h-0 flex-col lg:flex-row",
1049
+ children: [
1050
+ /* @__PURE__ */ jsxs2("div", {
1051
+ className: "relative min-h-[52%] min-w-0 flex-[1.6] lg:h-full lg:min-h-0 lg:flex-[1.15]",
1052
+ children: [
1053
+ /* @__PURE__ */ jsx2(Swiper2, {
1054
+ initialSlide: current,
1055
+ dir: "ltr",
1056
+ modules: [Zoom2, Virtual, Mousewheel2],
1057
+ className: "h-full w-full",
1058
+ slidesPerView: 1,
1059
+ mousewheel: { forceToAxis: true },
1060
+ zoom: { maxRatio: 3, toggle: true },
1061
+ virtual: true,
1062
+ onSwiper: handleSwiper,
1063
+ onSlideChange: handleSlideChange,
1064
+ children: items.map((item, i) => /* @__PURE__ */ jsx2(SwiperSlide2, {
1065
+ virtualIndex: i,
1066
+ zoom: Boolean(item.imageUrl),
1067
+ className: "flex items-center justify-center",
1068
+ children: /* @__PURE__ */ jsx2("div", {
1069
+ className: "swiper-zoom-container flex h-full w-full items-center justify-center bg-[var(--reader-surface)]",
1070
+ children: item.imageUrl ? /* @__PURE__ */ jsxs2("button", {
1071
+ type: "button",
1072
+ className: "group relative flex h-full w-full cursor-zoom-in items-center justify-center border-0 bg-transparent p-0",
1073
+ onClick: () => openLightbox(i),
1074
+ "aria-label": `Agrandir la page ${item.number}`,
1075
+ children: [
1076
+ /* @__PURE__ */ jsx2("img", {
1077
+ src: imageSrc(item.imageUrl, 1080),
1078
+ srcSet: imageSrcSet(item.imageUrl, [828, 1080, 1920]) || undefined,
1079
+ sizes: "(min-width: 1024px) 900px, 100vw",
1080
+ alt: `${title} — page ${item.number}`,
1081
+ loading: Math.abs(i - current) <= 1 ? "eager" : "lazy",
1082
+ draggable: false,
1083
+ className: "max-h-full max-w-full object-contain transition-opacity group-hover:opacity-95"
1084
+ }),
1085
+ /* @__PURE__ */ jsx2("span", {
1086
+ 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",
1087
+ children: "Agrandir"
1088
+ })
1089
+ ]
1090
+ }) : /* @__PURE__ */ jsxs2("div", {
1091
+ className: "px-6 text-center text-[18px] text-[var(--reader-on-surface)]/50",
1092
+ children: [
1093
+ "Page ",
1094
+ item.number,
1095
+ " — pas d'image"
1096
+ ]
1097
+ })
1098
+ })
1099
+ }, `db-page-${item.number}-${i}`))
1100
+ }, "databook-ltr"),
1101
+ /* @__PURE__ */ jsx2("button", {
1102
+ type: "button",
1103
+ "aria-label": "Page précédente",
1104
+ onClick: goPrevPage,
1105
+ disabled: current <= 0,
1106
+ 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",
1107
+ children: /* @__PURE__ */ jsx2(ChevronLeft2, {
1108
+ size: 20,
1109
+ "aria-hidden": true
1110
+ })
1111
+ }),
1112
+ /* @__PURE__ */ jsx2("button", {
1113
+ type: "button",
1114
+ "aria-label": "Page suivante",
1115
+ onClick: goNextPage,
1116
+ disabled: current >= total - 1,
1117
+ 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",
1118
+ children: /* @__PURE__ */ jsx2(ChevronRight2, {
1119
+ size: 20,
1120
+ "aria-hidden": true
1121
+ })
1122
+ })
1123
+ ]
1124
+ }),
1125
+ /* @__PURE__ */ jsx2("div", {
1126
+ 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",
1127
+ children: legendBlock
1128
+ })
1129
+ ]
1130
+ }) : /* @__PURE__ */ jsx2("div", {
1131
+ ref: scrollParentRef,
1132
+ tabIndex: 0,
1133
+ 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)]",
1134
+ "aria-label": `${title} — lecture verticale`,
1135
+ children: /* @__PURE__ */ jsx2("div", {
1136
+ style: {
1137
+ height: virtualizer.getTotalSize(),
1138
+ width: "100%",
1139
+ position: "relative"
1140
+ },
1141
+ children: virtualizer.getVirtualItems().map((vi) => {
1142
+ const item = items[vi.index];
1143
+ if (!item)
1144
+ return null;
1145
+ return /* @__PURE__ */ jsx2("div", {
1146
+ "data-index": vi.index,
1147
+ ref: virtualizer.measureElement,
1148
+ style: virtualRowStyle(vi.start),
1149
+ className: "snap-start snap-always border-b border-white/5 px-2 py-4 sm:px-4 sm:py-6",
1150
+ children: /* @__PURE__ */ jsxs2("div", {
1151
+ className: "group/planche mx-auto flex w-full max-w-3xl flex-col gap-3",
1152
+ children: [
1153
+ /* @__PURE__ */ jsxs2("span", {
1154
+ className: "text-[18px] font-bold uppercase tracking-[0.2em] text-[var(--reader-accent)]/70",
1155
+ children: [
1156
+ "Page ",
1157
+ item.number
1158
+ ]
1159
+ }),
1160
+ item.imageUrl ? /* @__PURE__ */ jsxs2("button", {
1161
+ type: "button",
1162
+ className: "group relative cursor-zoom-in border-0 bg-transparent p-0 text-left focus-visible:outline-none",
1163
+ onClick: () => openLightbox(vi.index),
1164
+ "aria-label": `Ouvrir la page ${item.number} en plein écran`,
1165
+ children: [
1166
+ /* @__PURE__ */ jsx2("img", {
1167
+ src: imageSrc(item.imageUrl, 1080),
1168
+ srcSet: imageSrcSet(item.imageUrl, [828, 1080, 1920]) || undefined,
1169
+ sizes: "(min-width: 768px) 768px, 100vw",
1170
+ alt: `${title} — page ${item.number}`,
1171
+ loading: Math.abs(vi.index - current) <= 3 ? "eager" : "lazy",
1172
+ fetchPriority: vi.index === current ? "high" : "auto",
1173
+ decoding: "async",
1174
+ draggable: false,
1175
+ 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"
1176
+ }),
1177
+ /* @__PURE__ */ jsxs2("span", {
1178
+ 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",
1179
+ children: [
1180
+ /* @__PURE__ */ jsx2(ZoomIn, {
1181
+ size: 12,
1182
+ "aria-hidden": true
1183
+ }),
1184
+ "Plein écran"
1185
+ ]
1186
+ })
1187
+ ]
1188
+ }) : /* @__PURE__ */ jsx2("div", {
1189
+ 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",
1190
+ children: "Pas d'image pour cette page"
1191
+ }),
1192
+ texteDe(item) ? /* @__PURE__ */ jsx2("div", {
1193
+ className: "rounded-md border border-[var(--reader-outline)]/40 bg-[var(--reader-surface)]/50 px-4 py-3",
1194
+ children: renderTranscription?.({
1195
+ text: texteDe(item),
1196
+ page: item.number,
1197
+ verified: item.verifiee
1198
+ }) ?? texteDe(item)
1199
+ }) : null
1200
+ ]
1201
+ })
1202
+ }, `${baseId}-${vi.key}`);
1203
+ })
1204
+ })
1205
+ })
1206
+ }),
1207
+ mode === "paged" && total > 0 && total <= SHOW_STRIP_MAX && /* @__PURE__ */ jsx2("div", {
1208
+ 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",
1209
+ children: /* @__PURE__ */ jsx2("div", {
1210
+ className: "mx-auto flex gap-2",
1211
+ children: items.map((item, idx) => /* @__PURE__ */ jsxs2("button", {
1212
+ type: "button",
1213
+ onClick: () => goTo(idx),
1214
+ className: `relative h-16 w-12 flex-shrink-0 overflow-hidden rounded border-2 transition-all ${current === idx ? "scale-105 border-[var(--reader-accent)] shadow-[0_0_10px_rgba(255,178,0,0.4)]" : "border-white/10 hover:border-white/30"}`,
1215
+ title: `Page ${item.number}`,
1216
+ "aria-label": `Aller à la page ${item.number}`,
1217
+ "aria-current": current === idx ? "true" : undefined,
1218
+ children: [
1219
+ item.imageUrl ? /* @__PURE__ */ jsx2("img", {
1220
+ src: imageSrc(item.imageUrl, 96),
1221
+ alt: "",
1222
+ className: "h-full w-full object-cover",
1223
+ loading: "lazy"
1224
+ }) : /* @__PURE__ */ jsx2("span", {
1225
+ className: "flex h-full w-full items-center justify-center bg-zinc-900 text-[18px] text-[var(--reader-on-surface)]/50",
1226
+ children: "txt"
1227
+ }),
1228
+ /* @__PURE__ */ jsx2("span", {
1229
+ 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)]",
1230
+ children: item.number
1231
+ })
1232
+ ]
1233
+ }, `thumb-${item.number}-${idx}`))
1234
+ })
1235
+ }),
1236
+ mode === "paged" && total > SHOW_STRIP_MAX && /* @__PURE__ */ jsxs2("form", {
1237
+ 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",
1238
+ onSubmit: (e) => {
1239
+ e.preventDefault();
1240
+ const field = e.currentTarget.elements.namedItem("goto");
1241
+ const wanted = Number(field?.value);
1242
+ if (!Number.isFinite(wanted))
1243
+ return;
1244
+ const byNumber = items.findIndex((it) => it.number === Math.trunc(wanted));
1245
+ if (byNumber >= 0)
1246
+ goTo(byNumber);
1247
+ else
1248
+ goTo(Math.trunc(wanted) - 1);
1249
+ },
1250
+ children: [
1251
+ /* @__PURE__ */ jsx2("label", {
1252
+ htmlFor: `${baseId}-goto`,
1253
+ className: "text-[18px] font-black uppercase tracking-wider text-[var(--reader-on-surface)]",
1254
+ children: "Aller à n°"
1255
+ }),
1256
+ /* @__PURE__ */ jsx2("input", {
1257
+ id: `${baseId}-goto`,
1258
+ name: "goto",
1259
+ type: "number",
1260
+ defaultValue: currentNum,
1261
+ 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)]"
1262
+ }, currentNum),
1263
+ /* @__PURE__ */ jsx2("button", {
1264
+ type: "submit",
1265
+ className: "m3-reader-btn-active !py-1 !px-3.5",
1266
+ children: "OK"
1267
+ })
1268
+ ]
1269
+ }),
1270
+ lightbox && currentImage && /* @__PURE__ */ jsxs2("div", {
1271
+ ref: lightboxRef,
1272
+ tabIndex: -1,
1273
+ role: "dialog",
1274
+ "aria-modal": "true",
1275
+ "aria-label": `${title} — page ${currentNum} agrandie`,
1276
+ className: "fixed inset-0 z-[100] flex flex-col bg-[var(--reader-surface)]/95",
1277
+ onClick: () => setLightbox(false),
1278
+ children: [
1279
+ /* @__PURE__ */ jsxs2("div", {
1280
+ className: "flex items-center gap-3 border-b-2 border-[var(--reader-outline)] bg-[var(--reader-surface)] px-4 py-3",
1281
+ children: [
1282
+ /* @__PURE__ */ jsx2("span", {
1283
+ className: "min-w-0 flex-1 truncate font-sans text-[18px] font-extrabold uppercase tracking-wide text-[var(--reader-on-surface)]",
1284
+ children: title
1285
+ }),
1286
+ /* @__PURE__ */ jsx2("span", {
1287
+ className: "m3-reader-badge bg-[var(--reader-accent)] text-[var(--reader-on-accent)] px-2 py-0.5 text-[18px] font-black",
1288
+ children: /* @__PURE__ */ jsxs2("span", {
1289
+ children: [
1290
+ "n°",
1291
+ currentNum
1292
+ ]
1293
+ })
1294
+ }),
1295
+ /* @__PURE__ */ jsx2("button", {
1296
+ type: "button",
1297
+ className: "m3-reader-btn ml-auto",
1298
+ onClick: (e) => {
1299
+ e.stopPropagation();
1300
+ setLightbox(false);
1301
+ },
1302
+ "aria-label": "Fermer",
1303
+ children: /* @__PURE__ */ jsx2(X, {
1304
+ size: 16,
1305
+ "aria-hidden": true
1306
+ })
1307
+ })
1308
+ ]
1309
+ }),
1310
+ /* @__PURE__ */ jsxs2("div", {
1311
+ className: "relative flex min-h-0 flex-1 items-center justify-center p-2 sm:p-4",
1312
+ onClick: (e) => e.stopPropagation(),
1313
+ onTouchStart,
1314
+ onTouchEnd,
1315
+ children: [
1316
+ /* @__PURE__ */ jsx2("img", {
1317
+ src: currentImage,
1318
+ alt: `${title} — page ${currentNum}`,
1319
+ className: "max-h-full max-w-full object-contain",
1320
+ draggable: false
1321
+ }),
1322
+ /* @__PURE__ */ jsx2("button", {
1323
+ type: "button",
1324
+ "aria-label": "Page précédente",
1325
+ onClick: () => goTo(current - 1),
1326
+ disabled: current <= 0,
1327
+ 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",
1328
+ children: /* @__PURE__ */ jsx2(ChevronLeft2, {
1329
+ size: 22,
1330
+ "aria-hidden": true
1331
+ })
1332
+ }),
1333
+ /* @__PURE__ */ jsx2("button", {
1334
+ type: "button",
1335
+ "aria-label": "Page suivante",
1336
+ onClick: () => goTo(current + 1),
1337
+ disabled: current >= total - 1,
1338
+ 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",
1339
+ children: /* @__PURE__ */ jsx2(ChevronRight2, {
1340
+ size: 22,
1341
+ "aria-hidden": true
1342
+ })
1343
+ })
1344
+ ]
1345
+ })
1346
+ ]
1347
+ })
1348
+ ]
1349
+ });
1350
+ }
1351
+ // packages/m3-reader/src/CollectionReader.tsx
1352
+ import { useEffect as useEffect5, useId as useId3, useMemo as useMemo3, useState as useState4 } from "react";
1353
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1354
+ "use client";
1355
+ function CollectionReader({ title, editions, initialEdition, initialChapter, emptyLabel, contentsLabel, previousLabel, nextLabel, onSelectionChange, renderChapter }) {
1356
+ const contentsId = useId3();
1357
+ const available = useMemo3(() => editions.map((edition) => ({ ...edition, chapters: edition.chapters.filter((chapter) => chapter.pageCount > 0) })).filter((edition) => edition.chapters.length > 0), [editions]);
1358
+ const [editionId, setEditionId] = useState4(() => available.find((edition) => edition.id === initialEdition)?.id ?? available[0]?.id ?? "");
1359
+ const edition = available.find((item) => item.id === editionId) ?? available[0];
1360
+ const [chapterIndex, setChapterIndex] = useState4(() => Math.max(0, edition?.chapters.findIndex((chapter) => String(chapter.id) === String(initialChapter)) ?? 0));
1361
+ const [contentsOpen, setContentsOpen] = useState4(false);
1362
+ const safeIndex = Math.min(chapterIndex, Math.max(0, (edition?.chapters.length ?? 0) - 1));
1363
+ const chapter = edition?.chapters[safeIndex];
1364
+ useEffect5(() => {
1365
+ if (edition && chapter)
1366
+ onSelectionChange?.(edition, chapter);
1367
+ }, [edition, chapter, onSelectionChange]);
1368
+ if (!edition || !chapter) {
1369
+ return /* @__PURE__ */ jsx3("p", {
1370
+ className: "m3-reader-collection-empty",
1371
+ "data-m3-component": "CollectionReader",
1372
+ "data-testid": "collection-reader-empty",
1373
+ children: emptyLabel
1374
+ });
1375
+ }
1376
+ return /* @__PURE__ */ jsxs3("section", {
1377
+ className: "m3-reader m3-reader-collection",
1378
+ "aria-label": title,
1379
+ "data-m3-component": "CollectionReader",
1380
+ "data-testid": "collection-reader",
1381
+ children: [
1382
+ /* @__PURE__ */ jsxs3("div", {
1383
+ className: "m3-reader-collection-toolbar",
1384
+ children: [
1385
+ /* @__PURE__ */ jsx3("div", {
1386
+ className: "m3-reader-collection-editions",
1387
+ role: "group",
1388
+ "aria-label": title,
1389
+ children: available.map((item) => /* @__PURE__ */ jsx3("button", {
1390
+ type: "button",
1391
+ className: item.id === edition.id ? "m3-reader-btn-active" : "m3-reader-btn",
1392
+ "aria-pressed": item.id === edition.id,
1393
+ onClick: () => {
1394
+ setEditionId(item.id);
1395
+ setChapterIndex(0);
1396
+ setContentsOpen(false);
1397
+ },
1398
+ children: item.label
1399
+ }, item.id))
1400
+ }),
1401
+ /* @__PURE__ */ jsxs3("button", {
1402
+ type: "button",
1403
+ className: "m3-reader-btn",
1404
+ "aria-expanded": contentsOpen,
1405
+ "aria-controls": contentsOpen ? contentsId : undefined,
1406
+ onClick: () => setContentsOpen((value) => !value),
1407
+ children: [
1408
+ contentsLabel,
1409
+ " (",
1410
+ edition.chapters.length,
1411
+ ")"
1412
+ ]
1413
+ })
1414
+ ]
1415
+ }),
1416
+ contentsOpen && /* @__PURE__ */ jsx3("div", {
1417
+ id: contentsId,
1418
+ className: "m3-reader-collection-contents",
1419
+ role: "group",
1420
+ "aria-label": contentsLabel,
1421
+ children: edition.chapters.map((item, index) => /* @__PURE__ */ jsxs3("button", {
1422
+ type: "button",
1423
+ className: index === safeIndex ? "m3-reader-btn-active" : "m3-reader-btn",
1424
+ "aria-current": index === safeIndex ? "page" : undefined,
1425
+ disabled: item.pageCount === 0,
1426
+ onClick: () => {
1427
+ setChapterIndex(index);
1428
+ setContentsOpen(false);
1429
+ },
1430
+ children: [
1431
+ item.label,
1432
+ " · ",
1433
+ item.pageCount
1434
+ ]
1435
+ }, item.id))
1436
+ }),
1437
+ renderChapter(edition, chapter, safeIndex),
1438
+ /* @__PURE__ */ jsxs3("nav", {
1439
+ className: "m3-reader-collection-navigation",
1440
+ "aria-label": contentsLabel,
1441
+ children: [
1442
+ /* @__PURE__ */ jsx3("button", {
1443
+ type: "button",
1444
+ className: "m3-reader-btn",
1445
+ disabled: safeIndex === 0,
1446
+ onClick: () => setChapterIndex(safeIndex - 1),
1447
+ children: previousLabel
1448
+ }),
1449
+ /* @__PURE__ */ jsxs3("span", {
1450
+ "aria-live": "polite",
1451
+ children: [
1452
+ chapter.label,
1453
+ " (",
1454
+ safeIndex + 1,
1455
+ "/",
1456
+ edition.chapters.length,
1457
+ ")"
1458
+ ]
1459
+ }),
1460
+ /* @__PURE__ */ jsx3("button", {
1461
+ type: "button",
1462
+ className: "m3-reader-btn",
1463
+ disabled: safeIndex >= edition.chapters.length - 1,
1464
+ onClick: () => setChapterIndex(safeIndex + 1),
1465
+ children: nextLabel
1466
+ })
1467
+ ]
1468
+ })
1469
+ ]
1470
+ });
1471
+ }
1472
+ export {
1473
+ CollectionReader,
1474
+ DatabookReader,
1475
+ MangaReader
1476
+ };
1477
+
1478
+ //# debugId=F914B781AD4BAB2F64756E2164756E21