fix(web): lecteur PDF — pages blanches après rendu et base minimale stable
Le canvas PDF restait vide après rendu (blank-detected): la détection d'encre reposait sur un seuil de blanc trop sensible et un fond de canvas non maîtrisé. Le rendu passe à un fond sentinel explicite avec classification rendered-ok/blank-detected et un seuil de pixels visibles. Le lecteur est par ailleurs ramené à une base minimale stable: layout plein viewport (app-main-reader), suppression des modes de lecture et des préférences par livre, helpers de pagination/viewport extraits dans readerLayout.ts. Shell, lecteurs CBZ/EPUB et styles alignés sur cette base. Refs #31 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,39 +1,36 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { CbzPagesDto, ReaderMode } from "../api/types";
|
||||
import type { CbzPagesDto } from "../api/types";
|
||||
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
|
||||
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,
|
||||
mode,
|
||||
onPageCommit,
|
||||
onControlsChange
|
||||
}: {
|
||||
bookId: number;
|
||||
page: number;
|
||||
mode: ReaderMode;
|
||||
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);
|
||||
const [documentError, setDocumentError] = useState<string>();
|
||||
const [pageError, setPageError] = useState<string>();
|
||||
const [documentAttempt, setDocumentAttempt] = useState(0);
|
||||
const [retryAttempt, setRetryAttempt] = useState(0);
|
||||
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
||||
const [imageSize, setImageSize] = useState<ReaderSize | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(undefined);
|
||||
setImageError(false);
|
||||
restoredRef.current = false;
|
||||
setDocumentError(undefined);
|
||||
setPageError(undefined);
|
||||
setPages(null);
|
||||
api
|
||||
.cbzPages(bookId)
|
||||
.then((nextPages) => {
|
||||
@ -41,86 +38,95 @@ export function CbzReader({
|
||||
setPages(nextPages);
|
||||
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setError("Archive CBZ indisponible.");
|
||||
.catch((error) => {
|
||||
if (!alive) return;
|
||||
setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible.");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [bookId]);
|
||||
}, [bookId, documentAttempt]);
|
||||
|
||||
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 currentPage = clampReaderPage(page, pageCount);
|
||||
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
||||
const fittedSize = viewportSize && imageSize ? orientedContainPageSize(viewportSize, imageSize) : null;
|
||||
const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined;
|
||||
|
||||
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");
|
||||
if (!pages) return;
|
||||
const target = clampReaderPage(nextPage, pages.pageCount);
|
||||
setPageError(undefined);
|
||||
onPageCommit(target, pages.pageCount, 1, "immediate");
|
||||
},
|
||||
[mode, onPageCommit, pageCount]
|
||||
[onPageCommit, pages]
|
||||
);
|
||||
|
||||
const goTop = useCallback(() => go(1), [go]);
|
||||
useEffect(() => {
|
||||
setPageError(undefined);
|
||||
setImageSize(null);
|
||||
}, [bookId, currentPage, retryAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
||||
const observedElement = stage ?? frame;
|
||||
if (!observedElement) return;
|
||||
const updateSize = () => {
|
||||
const rect = observedElement.getBoundingClientRect();
|
||||
const nextSize = readableViewportSize({ width: rect.width, height: rect.height });
|
||||
if (nextSize) setViewportSize(nextSize);
|
||||
};
|
||||
updateSize();
|
||||
const frameId = requestAnimationFrame(updateSize);
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(observedElement);
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onControlsChange({
|
||||
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
|
||||
canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1,
|
||||
canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount,
|
||||
positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement",
|
||||
onPrevious: () => go(currentPage - 1),
|
||||
onNext: () => go(currentPage + 1)
|
||||
});
|
||||
}, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]);
|
||||
}, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]);
|
||||
|
||||
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) {
|
||||
if (documentError) {
|
||||
return (
|
||||
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
<div className="reader-fallback reader-fallback-error">
|
||||
<span>{documentError}</span>
|
||||
<button className="ghost-button" onClick={() => setDocumentAttempt((attempt) => attempt + 1)}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pages) {
|
||||
return (
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
<div className="reader-fallback">
|
||||
<span>{error ?? "Page CBZ indisponible."}</span>
|
||||
<button className="ghost-button" onClick={() => go(currentPage)}>
|
||||
<span>Chargement de l'archive</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageError) {
|
||||
return (
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
<div className="reader-fallback reader-fallback-error">
|
||||
<span>{pageError}</span>
|
||||
<button className="ghost-button" onClick={() => setRetryAttempt((attempt) => attempt + 1)}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
@ -129,26 +135,25 @@ export function CbzReader({
|
||||
}
|
||||
|
||||
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>
|
||||
) : (
|
||||
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
||||
</figure>
|
||||
)}
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||
{!imageSize && (
|
||||
<div className="reader-fallback">
|
||||
<span>Page {currentPage}</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={`${currentPage}-${retryAttempt}`}
|
||||
src={api.cbzPageUrl(bookId, currentPage)}
|
||||
alt={currentName ?? `Page ${currentPage}`}
|
||||
style={imageStyle}
|
||||
hidden={!imageSize}
|
||||
onLoad={(event) => {
|
||||
setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
|
||||
}}
|
||||
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user