fix(web): lecteur PDF/CBZ — pages blanches, timeouts et modes fullscreen

- Rendu PDF via module dédié pdfRender (worker), correction des pages
  blanches et gestion des timeouts de rendu
- Lecteur comics : crop plein écran, locators de page unifiés (pdf/cbz/cbr)
  avec ancrage pour le calcul de progression
- Sauvegarde de progression mise en file (queueSave) pour éviter les
  pertes lors des navigations rapides
- nginx/vite : en-têtes et proxy alignés pour le streaming des livres

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-24 09:11:25 +02:00
parent d79f502dd2
commit 84e789acc4
11 changed files with 494 additions and 104 deletions

View File

@ -1,8 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { CbzPagesDto, ReaderMode } from "../api/types";
import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued";
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
return element?.closest(".reader-stage") as HTMLElement | null;
}
export function CbzReader({
bookId,
page,
@ -13,22 +19,27 @@ export function CbzReader({
bookId: number;
page: number;
mode: ReaderMode;
onPageCommit: (page: number, pages: number) => void;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const frameRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [error, setError] = useState<string>();
const [imageError, setImageError] = useState(false);
const [visiblePage, setVisiblePage] = useState(page);
useEffect(() => {
let alive = true;
setError(undefined);
setImageError(false);
restoredRef.current = false;
api
.cbzPages(bookId)
.then((nextPages) => {
if (!alive) return;
setPages(nextPages);
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount);
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
})
.catch(() => {
if (alive) setError("Archive CBZ indisponible.");
@ -40,34 +51,103 @@ export function CbzReader({
const pageCount = pages?.pageCount ?? 1;
const currentPage = Math.max(1, Math.min(page, pageCount));
const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pageCount)) : currentPage;
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
const go = useCallback((nextPage: number) => {
setImageError(false);
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
}, [onPageCommit, pageCount]);
const go = useCallback(
(nextPage: number) => {
const target = Math.max(1, Math.min(nextPage, pageCount));
setImageError(false);
if (mode === "vertical") {
frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
setVisiblePage(target);
}
onPageCommit(target, pageCount, mode === "vertical" ? 0 : 1, "immediate");
},
[mode, onPageCommit, pageCount]
);
const goTop = useCallback(() => go(1), [go]);
useEffect(() => {
onControlsChange({
canPrevious: !error && !imageError && currentPage > 1,
canNext: !error && !imageError && currentPage < pageCount,
positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive",
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
canPrevious: !error && !imageError && displayedPage > 1,
canNext: !error && !imageError && displayedPage < pageCount,
canTop: !error && !imageError && displayedPage > 1,
positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive",
onPrevious: () => go(displayedPage - 1),
onNext: () => go(displayedPage + 1),
onTop: goTop
});
}, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]);
}, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]);
return (
<div className={`cbz-reader cbz-reader-${mode}`}>
{error || imageError ? (
useEffect(() => {
if (mode !== "vertical") return;
const root = scrollContainerFor(frameRef.current);
if (!root || !pages) return;
const handleScroll = () => {
const rootRect = root.getBoundingClientRect();
const frames = Array.from(frameRef.current?.querySelectorAll<HTMLElement>("[data-reader-page]") ?? []);
const active = frames
.map((element) => {
const rect = element.getBoundingClientRect();
const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top);
const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height)));
return { page: Number(element.dataset.readerPage), overlap, anchor };
})
.filter((item) => Number.isInteger(item.page) && item.overlap > 0)
.sort((left, right) => right.overlap - left.overlap)[0];
if (!active) return;
setVisiblePage(active.page);
onPageCommit(active.page, pageCount, active.anchor, "queued");
};
root.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => root.removeEventListener("scroll", handleScroll);
}, [mode, onPageCommit, pageCount, pages]);
useEffect(() => {
if (mode !== "vertical" || !pages || restoredRef.current) return;
restoredRef.current = true;
requestAnimationFrame(() => {
frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
setVisiblePage(currentPage);
});
}, [currentPage, mode, pages]);
if (error || imageError) {
return (
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
<div className="reader-fallback">
<span>{error ?? "Page CBZ indisponible."}</span>
<button className="ghost-button" onClick={() => go(currentPage)}>
Reessayer
</button>
</div>
</div>
);
}
return (
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
{mode === "vertical" && pages ? (
<div className="comic-strip">
{pages.pages.map((item) => (
<figure className="comic-page-frame" data-reader-page={item.page} key={item.page}>
<img
src={api.cbzPageUrl(bookId, item.page)}
alt={item.name ?? `Page ${item.page}`}
loading="lazy"
decoding="async"
onError={() => setImageError(true)}
/>
</figure>
))}
</div>
) : (
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
<figure className="comic-page-frame" data-reader-page={currentPage}>
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
</figure>
)}
</div>
);