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:
@ -41,7 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
|||||||
<AdminPage />
|
<AdminPage />
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AppShell session={session}>{content}</AppShell>;
|
return (
|
||||||
|
<AppShell session={session} readerLayout={route.name === "reader"}>
|
||||||
|
{content}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const navItems = [
|
|||||||
{ href: "/me", label: "Profil", icon: UserRound }
|
{ href: "/me", label: "Profil", icon: UserRound }
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({ children, session }: { children: ReactNode; session: Session }) {
|
export function AppShell({ children, session, readerLayout = false }: { children: ReactNode; session: Session; readerLayout?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<aside className="side-rail">
|
<aside className="side-rail">
|
||||||
@ -32,7 +32,7 @@ export function AppShell({ children, session }: { children: ReactNode; session:
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
|
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main>{children}</main>
|
<main className={readerLayout ? "app-main-reader" : undefined}>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { EpubReader } from "../reader/EpubReader";
|
|||||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||||
import { PdfReader } from "../reader/PdfReader";
|
import { PdfReader } from "../reader/PdfReader";
|
||||||
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
|
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
|
||||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
|
||||||
const idleControls: ReaderControls = {
|
const idleControls: ReaderControls = {
|
||||||
@ -23,9 +22,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||||
const [controlsVisible, setControlsVisible] = useState(true);
|
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||||
const { progress, saving, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
|
||||||
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
|
|
||||||
|
|
||||||
async function loadBook() {
|
async function loadBook() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -47,7 +44,6 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReaderControls(idleControls);
|
setReaderControls(idleControls);
|
||||||
setControlsVisible(true);
|
|
||||||
}, [bookId]);
|
}, [bookId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -80,21 +76,15 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[book?.format, queueSave, save]
|
[book?.format, queueSave, save]
|
||||||
);
|
);
|
||||||
|
|
||||||
const readerError = error ?? progressError ?? preferencesError;
|
const readerError = error ?? progressError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderShell
|
<ReaderShell
|
||||||
title={book?.title ?? "Ouverture du lecteur"}
|
title={book?.title ?? "Ouverture du lecteur"}
|
||||||
status={loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}
|
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
progress={progress?.percent ?? 0}
|
|
||||||
error={readerError}
|
error={readerError}
|
||||||
onRetry={error ? () => void loadBook() : undefined}
|
onRetry={error ? () => void loadBook() : undefined}
|
||||||
mode={preferences.mode}
|
|
||||||
onModeChange={setMode}
|
|
||||||
controls={readerControls}
|
controls={readerControls}
|
||||||
controlsVisible={controlsVisible}
|
|
||||||
onToggleControls={() => setControlsVisible((visible) => !visible)}
|
|
||||||
>
|
>
|
||||||
{!book ? (
|
{!book ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
@ -108,7 +98,6 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
url={fileUrl}
|
url={fileUrl}
|
||||||
page={page}
|
page={page}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
mode={preferences.mode}
|
|
||||||
onPageCommit={savePdfPage}
|
onPageCommit={savePdfPage}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
@ -116,7 +105,6 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
<CbzReader
|
<CbzReader
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
page={page}
|
page={page}
|
||||||
mode={preferences.mode}
|
|
||||||
onPageCommit={saveComicPage}
|
onPageCommit={saveComicPage}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
@ -125,7 +113,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
url={fileUrl}
|
url={fileUrl}
|
||||||
locator={progress?.locator}
|
locator={progress?.locator}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
mode={preferences.mode}
|
mode="horizontal"
|
||||||
onLocatorChange={saveEpubLocator}
|
onLocatorChange={saveEpubLocator}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,39 +1,36 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api/client";
|
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";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
|
||||||
type PageCommitStrategy = "immediate" | "queued";
|
type PageCommitStrategy = "immediate" | "queued";
|
||||||
|
|
||||||
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
|
||||||
return element?.closest(".reader-stage") as HTMLElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CbzReader({
|
export function CbzReader({
|
||||||
bookId,
|
bookId,
|
||||||
page,
|
page,
|
||||||
mode,
|
|
||||||
onPageCommit,
|
onPageCommit,
|
||||||
onControlsChange
|
onControlsChange
|
||||||
}: {
|
}: {
|
||||||
bookId: number;
|
bookId: number;
|
||||||
page: number;
|
page: number;
|
||||||
mode: ReaderMode;
|
|
||||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const frameRef = useRef<HTMLDivElement>(null);
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const restoredRef = useRef(false);
|
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
const [error, setError] = useState<string>();
|
const [documentError, setDocumentError] = useState<string>();
|
||||||
const [imageError, setImageError] = useState(false);
|
const [pageError, setPageError] = useState<string>();
|
||||||
const [visiblePage, setVisiblePage] = useState(page);
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setError(undefined);
|
setDocumentError(undefined);
|
||||||
setImageError(false);
|
setPageError(undefined);
|
||||||
restoredRef.current = false;
|
setPages(null);
|
||||||
api
|
api
|
||||||
.cbzPages(bookId)
|
.cbzPages(bookId)
|
||||||
.then((nextPages) => {
|
.then((nextPages) => {
|
||||||
@ -41,86 +38,95 @@ export function CbzReader({
|
|||||||
setPages(nextPages);
|
setPages(nextPages);
|
||||||
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
|
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
if (alive) setError("Archive CBZ indisponible.");
|
if (!alive) return;
|
||||||
|
setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible.");
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [bookId]);
|
}, [bookId, documentAttempt]);
|
||||||
|
|
||||||
const pageCount = pages?.pageCount ?? 1;
|
const pageCount = pages?.pageCount ?? 1;
|
||||||
const currentPage = Math.max(1, Math.min(page, pageCount));
|
const currentPage = clampReaderPage(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 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(
|
const go = useCallback(
|
||||||
(nextPage: number) => {
|
(nextPage: number) => {
|
||||||
const target = Math.max(1, Math.min(nextPage, pageCount));
|
if (!pages) return;
|
||||||
setImageError(false);
|
const target = clampReaderPage(nextPage, pages.pageCount);
|
||||||
if (mode === "vertical") {
|
setPageError(undefined);
|
||||||
frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
onPageCommit(target, pages.pageCount, 1, "immediate");
|
||||||
setVisiblePage(target);
|
|
||||||
}
|
|
||||||
onPageCommit(target, pageCount, mode === "vertical" ? 0 : 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(() => {
|
useEffect(() => {
|
||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !error && !imageError && displayedPage > 1,
|
canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1,
|
||||||
canNext: !error && !imageError && displayedPage < pageCount,
|
canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount,
|
||||||
canTop: !error && !imageError && displayedPage > 1,
|
positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement",
|
||||||
positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive",
|
onPrevious: () => go(currentPage - 1),
|
||||||
onPrevious: () => go(displayedPage - 1),
|
onNext: () => go(currentPage + 1)
|
||||||
onNext: () => go(displayedPage + 1),
|
|
||||||
onTop: goTop
|
|
||||||
});
|
});
|
||||||
}, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]);
|
}, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (documentError) {
|
||||||
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 (
|
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">
|
<div className="reader-fallback">
|
||||||
<span>{error ?? "Page CBZ indisponible."}</span>
|
<span>Chargement de l'archive</span>
|
||||||
<button className="ghost-button" onClick={() => go(currentPage)}>
|
</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
|
Reessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -129,26 +135,25 @@ export function CbzReader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
|
<div className="cbz-reader" ref={frameRef}>
|
||||||
{mode === "vertical" && pages ? (
|
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||||
<div className="comic-strip">
|
{!imageSize && (
|
||||||
{pages.pages.map((item) => (
|
<div className="reader-fallback">
|
||||||
<figure className="comic-page-frame" data-reader-page={item.page} key={item.page}>
|
<span>Page {currentPage}</span>
|
||||||
<img
|
</div>
|
||||||
src={api.cbzPageUrl(bookId, item.page)}
|
)}
|
||||||
alt={item.name ?? `Page ${item.page}`}
|
<img
|
||||||
loading="lazy"
|
key={`${currentPage}-${retryAttempt}`}
|
||||||
decoding="async"
|
src={api.cbzPageUrl(bookId, currentPage)}
|
||||||
onError={() => setImageError(true)}
|
alt={currentName ?? `Page ${currentPage}`}
|
||||||
/>
|
style={imageStyle}
|
||||||
</figure>
|
hidden={!imageSize}
|
||||||
))}
|
onLoad={(event) => {
|
||||||
</div>
|
setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
|
||||||
) : (
|
}}
|
||||||
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
|
||||||
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import { ReaderError } from "./ReaderError";
|
import { ReaderError } from "./ReaderError";
|
||||||
import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender";
|
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
||||||
import { configurePdfWorker } from "./pdfWorker";
|
import { configurePdfWorker } from "./pdfWorker";
|
||||||
|
import { clampReaderPage, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
import type { ReaderMode } from "../api/types";
|
|
||||||
|
|
||||||
configurePdfWorker(pdfjs);
|
configurePdfWorker(pdfjs);
|
||||||
|
|
||||||
@ -14,77 +14,196 @@ type PdfReaderProps = {
|
|||||||
url: string;
|
url: string;
|
||||||
page: number;
|
page: number;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
mode: ReaderMode;
|
|
||||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PdfPageCanvasProps = {
|
type PdfFailure = {
|
||||||
document: pdfjs.PDFDocumentProxy;
|
detail: string;
|
||||||
pageNumber: number;
|
technicalDetail: string;
|
||||||
mode: ReaderMode;
|
|
||||||
frameSize: { width: number; height: number };
|
|
||||||
onError: (message: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let pdfLifecycle = Promise.resolve();
|
type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed";
|
||||||
|
|
||||||
function enqueuePdfLifecycle<T>(work: () => Promise<T>) {
|
function pdfTechnicalMessage(error: unknown) {
|
||||||
const run = pdfLifecycle.then(work, work);
|
if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`;
|
||||||
pdfLifecycle = run.then(() => undefined, () => undefined);
|
if (typeof error === "string" && error.trim()) return error;
|
||||||
return run;
|
return "Erreur inconnue.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfFailure(source: string, detail: string, context: Record<string, unknown>, error?: unknown): PdfFailure {
|
||||||
|
return {
|
||||||
|
detail,
|
||||||
|
technicalDetail: JSON.stringify(
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
...context,
|
||||||
|
error: error === undefined ? undefined : pdfTechnicalMessage(error)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPdfTransitionError(error: unknown) {
|
function isPdfTransitionError(error: unknown) {
|
||||||
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled"));
|
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function pdfReaderErrorMessage(error: unknown) {
|
|
||||||
if (isPdfTransitionError(error)) return "Le lecteur PDF termine une ouverture precedente.";
|
|
||||||
return "Le PDF n'a pas pu s'ouvrir.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
||||||
return element?.closest(".reader-stage") as HTMLElement | null;
|
return element?.closest(".reader-stage") as HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) {
|
function afterNextPaint() {
|
||||||
|
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange }: PdfReaderProps) {
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
||||||
const [visible, setVisible] = useState(mode === "horizontal");
|
const [pages, setPages] = useState(1);
|
||||||
const [rendered, setRendered] = useState(false);
|
const [documentError, setDocumentError] = useState<PdfFailure>();
|
||||||
|
const [pageError, setPageError] = useState<PdfFailure>();
|
||||||
|
const [loadingDocument, setLoadingDocument] = useState(true);
|
||||||
|
const [loadingPage, setLoadingPage] = useState(false);
|
||||||
|
const [pageRendered, setPageRendered] = useState(false);
|
||||||
|
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
|
||||||
|
const [documentAttempt, setDocumentAttempt] = useState(0);
|
||||||
|
const [pageAttempt, setPageAttempt] = useState(0);
|
||||||
|
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
||||||
|
|
||||||
|
const currentPage = clampReaderPage(page, pages);
|
||||||
|
const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError));
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(nextPage: number) => {
|
||||||
|
const target = clampReaderPage(nextPage, pages);
|
||||||
|
setPageError(undefined);
|
||||||
|
onPageCommit(target, pages, 1, "immediate");
|
||||||
|
},
|
||||||
|
[onPageCommit, pages]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "horizontal") {
|
onControlsChange({
|
||||||
setVisible(true);
|
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
||||||
return;
|
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
||||||
|
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
|
||||||
|
onPrevious: () => go(currentPage - 1),
|
||||||
|
onNext: () => go(currentPage + 1)
|
||||||
|
});
|
||||||
|
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = frameRef.current;
|
||||||
|
const stage = scrollContainerFor(frame);
|
||||||
|
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(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
||||||
|
setLoadingDocument(true);
|
||||||
|
setDocumentError(undefined);
|
||||||
|
setPageError(undefined);
|
||||||
|
setPageRendered(false);
|
||||||
|
setPageStatus("idle");
|
||||||
|
setDocumentProxy(null);
|
||||||
|
|
||||||
|
async function loadDocument() {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
new URL(url, window.location.href);
|
||||||
|
} catch (error) {
|
||||||
|
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
|
||||||
|
}
|
||||||
|
configurePdfWorker(pdfjs);
|
||||||
|
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||||
|
const loadedDocument = await loadingTask.promise;
|
||||||
|
if (cancelled) {
|
||||||
|
await loadingTask.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPages(loadedDocument.numPages);
|
||||||
|
setDocumentProxy(loadedDocument);
|
||||||
|
setLoadingDocument(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled && isPdfTransitionError(error)) return;
|
||||||
|
if (!cancelled) {
|
||||||
|
setDocumentError(
|
||||||
|
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
|
||||||
|
? (error as PdfFailure)
|
||||||
|
: pdfFailure(
|
||||||
|
"getDocument",
|
||||||
|
"PDF: getDocument() a échoué.",
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
resolvedUrl: (() => {
|
||||||
|
try {
|
||||||
|
return new URL(url, window.location.href).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
workerSrc: pdfjs.GlobalWorkerOptions.workerSrc,
|
||||||
|
hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort)
|
||||||
|
},
|
||||||
|
error
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setLoadingDocument(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const wrapper = wrapperRef.current;
|
|
||||||
if (!wrapper) return;
|
void loadDocument();
|
||||||
const observer = new IntersectionObserver(
|
return () => {
|
||||||
(entries) => {
|
cancelled = true;
|
||||||
if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
|
void loadingTask?.destroy();
|
||||||
},
|
};
|
||||||
{ root: scrollContainerFor(wrapper), rootMargin: "900px 0px" }
|
}, [documentAttempt, url]);
|
||||||
);
|
|
||||||
observer.observe(wrapper);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!documentProxy || !viewportSize) return;
|
||||||
|
const loadedDocument = documentProxy;
|
||||||
|
const readerViewport = viewportSize;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let renderTask: pdfjs.RenderTask | undefined;
|
let renderTask: pdfjs.RenderTask | undefined;
|
||||||
setRendered(false);
|
setLoadingPage(true);
|
||||||
|
setPageError(undefined);
|
||||||
|
setPageRendered(false);
|
||||||
|
setPageStatus("loading");
|
||||||
|
|
||||||
async function renderPage() {
|
async function renderPage() {
|
||||||
try {
|
try {
|
||||||
const pdfPage = await document.getPage(pageNumber);
|
let pdfPage: pdfjs.PDFPageProxy;
|
||||||
|
try {
|
||||||
|
pdfPage = await loadedDocument.getPage(currentPage);
|
||||||
|
} catch (error) {
|
||||||
|
throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error);
|
||||||
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) {
|
||||||
|
throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage });
|
||||||
|
}
|
||||||
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||||
const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height });
|
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
|
||||||
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
||||||
const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio;
|
const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio;
|
||||||
const viewport = pdfPage.getViewport({ scale: renderScale });
|
const viewport = pdfPage.getViewport({ scale: renderScale });
|
||||||
@ -95,189 +214,136 @@ function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPa
|
|||||||
canvas.style.width = `${cssWidth}px`;
|
canvas.style.width = `${cssWidth}px`;
|
||||||
canvas.style.height = `${cssHeight}px`;
|
canvas.style.height = `${cssHeight}px`;
|
||||||
const context = canvas.getContext("2d", { alpha: false });
|
const context = canvas.getContext("2d", { alpha: false });
|
||||||
if (!context) return;
|
if (!context) {
|
||||||
context.fillStyle = "#f7f0df";
|
throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, {
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
pageNumber: currentPage,
|
||||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
canvasWidth: canvas.width,
|
||||||
await renderTask.promise;
|
canvasHeight: canvas.height
|
||||||
if (!pdfCanvasHasVisibleInk(context.getImageData(0, 0, canvas.width, canvas.height))) {
|
});
|
||||||
throw new Error(`La page PDF ${pageNumber} est blanche apres rendu.`);
|
}
|
||||||
|
if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) {
|
||||||
|
throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, {
|
||||||
|
pageNumber: currentPage,
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
cssWidth,
|
||||||
|
cssHeight,
|
||||||
|
readerViewport
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.fillStyle = pdfSentinelBackground();
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
try {
|
||||||
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||||
|
await renderTask.promise;
|
||||||
|
} catch (error) {
|
||||||
|
throw pdfFailure(
|
||||||
|
"render-failed",
|
||||||
|
`PDF: render() a échoué pour la page ${currentPage}.`,
|
||||||
|
{
|
||||||
|
result: "render-failed",
|
||||||
|
pageNumber: currentPage,
|
||||||
|
baseViewport: { width: baseViewport.width, height: baseViewport.height },
|
||||||
|
renderViewport: { width: viewport.width, height: viewport.height },
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
cssWidth,
|
||||||
|
cssHeight,
|
||||||
|
readerViewport
|
||||||
|
},
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await afterNextPaint();
|
||||||
|
if (cancelled) return;
|
||||||
|
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||||
|
if (renderResult === "blank-detected") {
|
||||||
|
setPageStatus("blank-detected");
|
||||||
|
setPageRendered(false);
|
||||||
|
setLoadingPage(false);
|
||||||
|
setPageError(
|
||||||
|
pdfFailure("blank-detected", `PDF: page ${currentPage} blanche détectée après rendu.`, {
|
||||||
|
result: "blank-detected",
|
||||||
|
pageNumber: currentPage,
|
||||||
|
baseViewport: { width: baseViewport.width, height: baseViewport.height },
|
||||||
|
renderViewport: { width: viewport.width, height: viewport.height },
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
cssWidth,
|
||||||
|
cssHeight,
|
||||||
|
readerViewport
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setPageStatus("rendered-ok");
|
||||||
|
setPageRendered(true);
|
||||||
|
setLoadingPage(false);
|
||||||
}
|
}
|
||||||
if (!cancelled) setRendered(true);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error));
|
if (cancelled || isPdfTransitionError(error)) return;
|
||||||
|
setPageRendered(false);
|
||||||
|
setPageStatus("render-failed");
|
||||||
|
setLoadingPage(false);
|
||||||
|
setPageError(
|
||||||
|
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
|
||||||
|
? (error as PdfFailure)
|
||||||
|
: pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void enqueuePdfLifecycle(renderPage);
|
void renderPage();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
renderTask?.cancel();
|
renderTask?.cancel();
|
||||||
};
|
};
|
||||||
}, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]);
|
}, [currentPage, documentProxy, pageAttempt, viewportSize]);
|
||||||
|
|
||||||
return (
|
if (documentError) {
|
||||||
<div className="pdf-page-frame" data-reader-page={pageNumber} ref={wrapperRef}>
|
return (
|
||||||
{!rendered && (
|
<div className="pdf-reader" ref={frameRef}>
|
||||||
<div className="reader-fallback">
|
|
||||||
<span>Page {pageNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<canvas ref={canvasRef} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
|
|
||||||
const frameRef = useRef<HTMLDivElement>(null);
|
|
||||||
const restoredRef = useRef(false);
|
|
||||||
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
|
||||||
const [pages, setPages] = useState(1);
|
|
||||||
const [visiblePage, setVisiblePage] = useState(page);
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [frameSize, setFrameSize] = useState({ width: 980, height: 900 });
|
|
||||||
|
|
||||||
const currentPage = Math.max(1, Math.min(page, pages));
|
|
||||||
const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pages)) : currentPage;
|
|
||||||
|
|
||||||
const go = useCallback(
|
|
||||||
(nextPage: number) => {
|
|
||||||
const target = Math.max(1, Math.min(nextPage, pages));
|
|
||||||
if (mode === "vertical") {
|
|
||||||
frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
|
||||||
onPageCommit(target, pages, 0, "immediate");
|
|
||||||
setVisiblePage(target);
|
|
||||||
} else {
|
|
||||||
onPageCommit(target, pages, 1, "immediate");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mode, onPageCommit, pages]
|
|
||||||
);
|
|
||||||
|
|
||||||
const goTop = useCallback(() => go(1), [go]);
|
|
||||||
const handlePageError = useCallback((message: string) => {
|
|
||||||
setError(message);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onControlsChange({
|
|
||||||
canPrevious: !loading && !error && displayedPage > 1,
|
|
||||||
canNext: !loading && !error && displayedPage < pages,
|
|
||||||
canTop: !loading && !error && displayedPage > 1,
|
|
||||||
positionLabel: loading ? "Ouverture PDF" : `${displayedPage} / ${pages}`,
|
|
||||||
onPrevious: () => go(displayedPage - 1),
|
|
||||||
onNext: () => go(displayedPage + 1),
|
|
||||||
onTop: goTop
|
|
||||||
});
|
|
||||||
}, [displayedPage, error, go, goTop, loading, onControlsChange, pages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const frame = frameRef.current;
|
|
||||||
if (!frame) return;
|
|
||||||
const updateSize = () => {
|
|
||||||
const rect = frame.getBoundingClientRect();
|
|
||||||
setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) });
|
|
||||||
};
|
|
||||||
updateSize();
|
|
||||||
const observer = new ResizeObserver(updateSize);
|
|
||||||
observer.observe(frame);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
|
||||||
setLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
setDocumentProxy(null);
|
|
||||||
restoredRef.current = false;
|
|
||||||
|
|
||||||
async function loadDocument() {
|
|
||||||
try {
|
|
||||||
configurePdfWorker(pdfjs);
|
|
||||||
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
|
||||||
const loadedDocument = await loadingTask.promise;
|
|
||||||
if (cancelled) {
|
|
||||||
await loadingTask.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPages(loadedDocument.numPages);
|
|
||||||
setDocumentProxy(loadedDocument);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (loadError) {
|
|
||||||
if (cancelled && isPdfTransitionError(loadError)) return;
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(pdfReaderErrorMessage(loadError));
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void enqueuePdfLifecycle(loadDocument);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
void loadingTask?.destroy();
|
|
||||||
};
|
|
||||||
}, [attempt, url]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "vertical") return;
|
|
||||||
const root = scrollContainerFor(frameRef.current);
|
|
||||||
if (!root || !documentProxy) 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, pages, active.anchor, "queued");
|
|
||||||
};
|
|
||||||
root.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
handleScroll();
|
|
||||||
return () => root.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [documentProxy, mode, onPageCommit, pages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "vertical" || !documentProxy || restoredRef.current) return;
|
|
||||||
restoredRef.current = true;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
|
|
||||||
setVisiblePage(currentPage);
|
|
||||||
});
|
|
||||||
}, [currentPage, documentProxy, mode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`pdf-reader pdf-reader-${mode}`} ref={frameRef}>
|
|
||||||
{error ? (
|
|
||||||
<ReaderError
|
<ReaderError
|
||||||
title="Lecture PDF indisponible"
|
title="Lecture PDF indisponible"
|
||||||
detail={error}
|
detail={documentError.detail}
|
||||||
|
technicalDetail={documentError.technicalDetail}
|
||||||
downloadUrl={url}
|
downloadUrl={url}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
onRetry={() => setAttempt((value) => value + 1)}
|
onRetry={() => setDocumentAttempt((value) => value + 1)}
|
||||||
/>
|
/>
|
||||||
) : loading || !documentProxy ? (
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pdf-reader" ref={frameRef}>
|
||||||
|
{loadingDocument || !documentProxy ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Ouverture PDF</span>
|
<span>Ouverture PDF</span>
|
||||||
</div>
|
</div>
|
||||||
) : mode === "vertical" ? (
|
) : pageError ? (
|
||||||
<div className="pdf-strip">
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||||
{Array.from({ length: pages }, (_, index) => (
|
<div className="reader-fallback reader-fallback-error">
|
||||||
<PdfPageCanvas key={index + 1} document={documentProxy} pageNumber={index + 1} mode={mode} frameSize={frameSize} onError={handlePageError} />
|
<span>{pageError.detail}</span>
|
||||||
))}
|
<button className="ghost-button" onClick={() => setPageAttempt((value) => value + 1)}>
|
||||||
|
Reessayer
|
||||||
|
</button>
|
||||||
|
<details>
|
||||||
|
<summary>Détail technique</summary>
|
||||||
|
<pre>{pageError.technicalDetail}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PdfPageCanvas document={documentProxy} pageNumber={currentPage} mode={mode} frameSize={frameSize} onError={handlePageError} />
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||||
|
{(!viewportSize || loadingPage || !pageRendered) && (
|
||||||
|
<div className="reader-fallback">
|
||||||
|
<span>Page {currentPage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,180 +1,79 @@
|
|||||||
import { ArrowLeft, ArrowRight, ArrowUp, Columns2, Maximize2, Minimize2, RotateCcw, Rows3, Save } from "lucide-react";
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { ErrorRibbon, Meter } from "../components/ui";
|
import { ErrorRibbon } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import type { ReaderMode } from "../api/types";
|
|
||||||
|
|
||||||
export type ReaderControls = {
|
export type ReaderControls = {
|
||||||
canPrevious: boolean;
|
canPrevious: boolean;
|
||||||
canNext: boolean;
|
canNext: boolean;
|
||||||
canTop?: boolean;
|
|
||||||
positionLabel: string;
|
positionLabel: string;
|
||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onTop?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReaderShellProps = {
|
type ReaderShellProps = {
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
|
||||||
backHref: string;
|
backHref: string;
|
||||||
progress: number;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
mode: ReaderMode;
|
|
||||||
onModeChange: (mode: ReaderMode) => void;
|
|
||||||
controls: ReaderControls;
|
controls: ReaderControls;
|
||||||
controlsVisible: boolean;
|
|
||||||
onToggleControls: () => void;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ReaderShell({
|
function isFormField(target: EventTarget | null) {
|
||||||
title,
|
const element = target as HTMLElement | null;
|
||||||
status,
|
const tagName = element?.tagName.toLowerCase();
|
||||||
backHref,
|
return Boolean(element?.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select");
|
||||||
progress,
|
}
|
||||||
error,
|
|
||||||
onRetry,
|
|
||||||
mode,
|
|
||||||
onModeChange,
|
|
||||||
controls,
|
|
||||||
controlsVisible,
|
|
||||||
onToggleControls,
|
|
||||||
children
|
|
||||||
}: ReaderShellProps) {
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
|
||||||
const root = document.querySelector(".reader-page");
|
|
||||||
if (!document.fullscreenElement) void root?.requestFullscreen();
|
|
||||||
else void document.exitFullscreen();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
export function ReaderShell({ title, backHref, error, onRetry, controls, children }: ReaderShellProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
updateFullscreen();
|
if (isFormField(event.target)) return;
|
||||||
document.addEventListener("fullscreenchange", updateFullscreen);
|
if (event.key === "ArrowLeft" && controls.canPrevious) {
|
||||||
return () => document.removeEventListener("fullscreenchange", updateFullscreen);
|
event.preventDefault();
|
||||||
}, []);
|
controls.onPrevious();
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowRight" && controls.canNext) {
|
||||||
|
event.preventDefault();
|
||||||
|
controls.onNext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [controls]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
|
<div className="reader-page reader-mode-horizontal">
|
||||||
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
|
<header className="reader-topbar">
|
||||||
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||||
<ArrowLeft size={17} />
|
<ArrowLeft size={17} />
|
||||||
Fiche
|
Retour
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<strong className="reader-title">{title}</strong>
|
||||||
<strong>{title}</strong>
|
<span className="reader-position">{controls.positionLabel}</span>
|
||||||
<span>{status}</span>
|
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||||
</div>
|
<ArrowLeft size={16} />
|
||||||
<div className="reader-toolbar">
|
Precedent
|
||||||
<button
|
</button>
|
||||||
className={`ghost-button reader-mode-button ${mode === "horizontal" ? "active" : ""}`}
|
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
|
||||||
onClick={() => onModeChange("horizontal")}
|
Suivant
|
||||||
aria-label="Lecture horizontale"
|
<ArrowRight size={16} />
|
||||||
title="Lecture horizontale"
|
</button>
|
||||||
>
|
</header>
|
||||||
<Columns2 size={18} />
|
{error && (
|
||||||
Horizontal
|
<div className="reader-status">
|
||||||
</button>
|
<ErrorRibbon message={error} />
|
||||||
<button
|
{onRetry && (
|
||||||
className={`ghost-button reader-mode-button ${mode === "vertical" ? "active" : ""}`}
|
<button className="ghost-button" onClick={onRetry}>
|
||||||
onClick={() => onModeChange("vertical")}
|
Reessayer
|
||||||
aria-label="Lecture verticale"
|
|
||||||
title="Lecture verticale"
|
|
||||||
>
|
|
||||||
<Rows3 size={18} />
|
|
||||||
Vertical
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button icon-only" onClick={toggleFullscreen} aria-label={isFullscreen ? "Quitter le plein ecran" : "Plein ecran"}>
|
|
||||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
|
||||||
</button>
|
|
||||||
{error && onRetry ? (
|
|
||||||
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
|
|
||||||
<RotateCcw size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<Save size={18} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
)}
|
||||||
<div className="reader-status" onClick={(event) => event.stopPropagation()}>
|
<main className="reader-stage">
|
||||||
<ErrorRibbon message={error} />
|
|
||||||
<Meter value={progress} />
|
|
||||||
</div>
|
|
||||||
<div className="reader-stage" onClick={onToggleControls}>
|
|
||||||
{mode === "horizontal" && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="reader-side-button reader-side-left"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
controls.onPrevious();
|
|
||||||
}}
|
|
||||||
disabled={!controls.canPrevious}
|
|
||||||
aria-label="Page precedente"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={22} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="reader-side-button reader-side-right"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
controls.onNext();
|
|
||||||
}}
|
|
||||||
disabled={!controls.canNext}
|
|
||||||
aria-label="Page suivante"
|
|
||||||
>
|
|
||||||
<ArrowRight size={22} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="reader-tap-zone reader-tap-left"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
controls.onPrevious();
|
|
||||||
}}
|
|
||||||
disabled={!controls.canPrevious}
|
|
||||||
aria-label="Page precedente"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="reader-tap-zone reader-tap-right"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
controls.onNext();
|
|
||||||
}}
|
|
||||||
disabled={!controls.canNext}
|
|
||||||
aria-label="Page suivante"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="reader-content">{children}</div>
|
<div className="reader-content">{children}</div>
|
||||||
</div>
|
</main>
|
||||||
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
|
|
||||||
{mode === "vertical" ? (
|
|
||||||
<button className="ghost-button" onClick={controls.onTop} disabled={!controls.canTop}>
|
|
||||||
<ArrowUp size={16} />
|
|
||||||
Haut
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Precedent
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span>{controls.positionLabel}</span>
|
|
||||||
{mode === "vertical" ? (
|
|
||||||
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Page precedente
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
|
|
||||||
Suivant
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,52 @@
|
|||||||
import type { ReaderMode } from "../api/types";
|
import type { ReaderSize } from "./readerLayout";
|
||||||
|
|
||||||
const PDF_RENDER_BACKGROUND = { red: 247, green: 240, blue: 223 };
|
const PDF_SENTINEL_BACKGROUND = { red: 247, green: 240, blue: 223 };
|
||||||
const PDF_BLANK_THRESHOLD = 245;
|
const PDF_BACKGROUND_TOLERANCE = 5;
|
||||||
const PDF_BACKGROUND_TOLERANCE = 4;
|
const PDF_VISIBLE_DELTA = 10;
|
||||||
|
const PDF_MIN_VISIBLE_PIXELS = 12;
|
||||||
|
|
||||||
export function pdfRenderScale(mode: ReaderMode, frameSize: { width: number; height: number }, pageSize: { width: number; height: number }) {
|
export type PdfRenderResult = "rendered-ok" | "blank-detected";
|
||||||
if (pageSize.width <= 0 || pageSize.height <= 0) return 1;
|
|
||||||
const fitWidth = frameSize.width / pageSize.width;
|
export function pdfRenderScale(frameSize: ReaderSize, pageSize: ReaderSize) {
|
||||||
if (mode === "vertical") return Math.min(1.65, fitWidth);
|
if (frameSize.width <= 0 || frameSize.height <= 0 || pageSize.width <= 0 || pageSize.height <= 0) return 1;
|
||||||
return Math.min(fitWidth, frameSize.height / pageSize.height);
|
return Math.min(frameSize.width / pageSize.width, frameSize.height / pageSize.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNearRenderBackground(red: number, green: number, blue: number) {
|
export function pdfCanvasVisible(pageRendered: boolean, hasPageError: boolean) {
|
||||||
|
return pageRendered && !hasPageError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearSentinel(red: number, green: number, blue: number) {
|
||||||
return (
|
return (
|
||||||
Math.abs(red - PDF_RENDER_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE &&
|
Math.abs(red - PDF_SENTINEL_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE &&
|
||||||
Math.abs(green - PDF_RENDER_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE &&
|
Math.abs(green - PDF_SENTINEL_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE &&
|
||||||
Math.abs(blue - PDF_RENDER_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE
|
Math.abs(blue - PDF_SENTINEL_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pdfCanvasHasVisibleInk(imageData: Pick<ImageData, "data">) {
|
function isVisibleInk(red: number, green: number, blue: number, alpha: number) {
|
||||||
|
if (alpha < 24) return false;
|
||||||
|
if (isNearSentinel(red, green, blue)) return false;
|
||||||
|
const distanceFromWhite = Math.max(255 - red, 255 - green, 255 - blue);
|
||||||
|
return distanceFromWhite >= PDF_VISIBLE_DELTA;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfCanvasHasVisibleContent(imageData: Pick<ImageData, "data">) {
|
||||||
|
let visiblePixels = 0;
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
for (let index = 0; index < data.length; index += 4) {
|
for (let index = 0; index < data.length; index += 4) {
|
||||||
const red = data[index] ?? 255;
|
if (isVisibleInk(data[index] ?? 255, data[index + 1] ?? 255, data[index + 2] ?? 255, data[index + 3] ?? 255)) {
|
||||||
const green = data[index + 1] ?? 255;
|
visiblePixels += 1;
|
||||||
const blue = data[index + 2] ?? 255;
|
if (visiblePixels >= PDF_MIN_VISIBLE_PIXELS) return true;
|
||||||
const alpha = data[index + 3] ?? 255;
|
}
|
||||||
if (alpha === 0) continue;
|
|
||||||
const nearWhite = red >= PDF_BLANK_THRESHOLD && green >= PDF_BLANK_THRESHOLD && blue >= PDF_BLANK_THRESHOLD;
|
|
||||||
if (!nearWhite && !isNearRenderBackground(red, green, blue)) return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classifyPdfCanvas(imageData: Pick<ImageData, "data">): PdfRenderResult {
|
||||||
|
return pdfCanvasHasVisibleContent(imageData) ? "rendered-ok" : "blank-detected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfSentinelBackground() {
|
||||||
|
return `rgb(${PDF_SENTINEL_BACKGROUND.red}, ${PDF_SENTINEL_BACKGROUND.green}, ${PDF_SENTINEL_BACKGROUND.blue})`;
|
||||||
|
}
|
||||||
|
|||||||
54
apps/web/src/reader/readerLayout.ts
Normal file
54
apps/web/src/reader/readerLayout.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
export type ReaderSize = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function clampReaderPage(page: number, pageCount: number) {
|
||||||
|
const safePageCount = Math.max(1, Math.floor(pageCount));
|
||||||
|
return Math.max(1, Math.min(Math.floor(page), safePageCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousReaderPage(page: number, pageCount: number) {
|
||||||
|
return clampReaderPage(page - 1, pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextReaderPage(page: number, pageCount: number) {
|
||||||
|
return clampReaderPage(page + 1, pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readerPositionLabel(page: number, pageCount: number) {
|
||||||
|
return `Page ${clampReaderPage(page, pageCount)} / ${Math.max(1, Math.floor(pageCount))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containPageSize(viewport: ReaderSize, page: ReaderSize): ReaderSize {
|
||||||
|
if (viewport.width <= 0 || viewport.height <= 0 || page.width <= 0 || page.height <= 0) {
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
const scale = Math.min(viewport.width / page.width, viewport.height / page.height);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.floor(page.width * scale)),
|
||||||
|
height: Math.max(1, Math.floor(page.height * scale))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orientedContainPageSize(viewport: ReaderSize, page: ReaderSize): ReaderSize {
|
||||||
|
if (viewport.width <= 0 || viewport.height <= 0 || page.width <= 0 || page.height <= 0) {
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
const fitByHeight = page.height >= page.width;
|
||||||
|
const primaryScale = fitByHeight ? viewport.height / page.height : viewport.width / page.width;
|
||||||
|
const crossScale = fitByHeight ? viewport.width / page.width : viewport.height / page.height;
|
||||||
|
const scale = Math.min(primaryScale, crossScale);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.floor(page.width * scale)),
|
||||||
|
height: Math.max(1, Math.floor(page.height * scale))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readableViewportSize(size: ReaderSize): ReaderSize | null {
|
||||||
|
if (size.width < 32 || size.height < 32) return null;
|
||||||
|
return {
|
||||||
|
width: Math.floor(size.width),
|
||||||
|
height: Math.floor(size.height)
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { epubFileName } from "./EpubReader";
|
import { epubFileName } from "./EpubReader";
|
||||||
import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender";
|
import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
|
||||||
|
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
|
||||||
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||||
import { readerErrorMessage } from "./ReaderError";
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
@ -32,13 +33,80 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("separates PDF fit-page and fit-width scales", () => {
|
it("fits PDF pages by containment inside the reader viewport", () => {
|
||||||
expect(pdfRenderScale("horizontal", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75);
|
expect(pdfRenderScale({ width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75);
|
||||||
expect(pdfRenderScale("vertical", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(1.65);
|
expect(pdfRenderScale({ width: 800, height: 600 }, { width: 800, height: 400 })).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects blank PDF canvas renders", () => {
|
it("does not expose a PDF canvas before a successful page render", () => {
|
||||||
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 247, 240, 223, 255]) })).toBe(false);
|
expect(pdfCanvasVisible(false, false)).toBe(false);
|
||||||
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 32, 28, 24, 255]) })).toBe(true);
|
expect(pdfCanvasVisible(true, true)).toBe(false);
|
||||||
|
expect(pdfCanvasVisible(true, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies blank PDF canvas data separately from render failures", () => {
|
||||||
|
expect(classifyPdfCanvas({ data: new Uint8ClampedArray([255, 255, 255, 255, 255, 255, 255, 255]) })).toBe("blank-detected");
|
||||||
|
expect(classifyPdfCanvas({ data: new Uint8ClampedArray([247, 240, 223, 255, 247, 240, 223, 255]) })).toBe("blank-detected");
|
||||||
|
|
||||||
|
const inkPixels = new Uint8ClampedArray(12 * 4);
|
||||||
|
for (let index = 0; index < inkPixels.length; index += 4) {
|
||||||
|
inkPixels[index] = 32;
|
||||||
|
inkPixels[index + 1] = 28;
|
||||||
|
inkPixels[index + 2] = 24;
|
||||||
|
inkPixels[index + 3] = 255;
|
||||||
|
}
|
||||||
|
expect(classifyPdfCanvas({ data: inkPixels })).toBe("rendered-ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat isolated PDF canvas noise as visible content", () => {
|
||||||
|
const noisyPixels = new Uint8ClampedArray([
|
||||||
|
255, 255, 255, 255,
|
||||||
|
32, 28, 24, 255,
|
||||||
|
255, 255, 255, 255
|
||||||
|
]);
|
||||||
|
expect(pdfCanvasHasVisibleContent({ data: noisyPixels })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies very light but non-empty PDF canvas data as rendered", () => {
|
||||||
|
const lightInkPixels = new Uint8ClampedArray(12 * 4);
|
||||||
|
for (let index = 0; index < lightInkPixels.length; index += 4) {
|
||||||
|
lightInkPixels[index] = 244;
|
||||||
|
lightInkPixels[index + 1] = 244;
|
||||||
|
lightInkPixels[index + 2] = 244;
|
||||||
|
lightInkPixels[index + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(classifyPdfCanvas({ data: lightInkPixels })).toBe("rendered-ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains page media inside the reader viewport without cropping", () => {
|
||||||
|
expect(containPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 });
|
||||||
|
expect(containPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits comic pages by intrinsic orientation without cropping", () => {
|
||||||
|
expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 });
|
||||||
|
expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 });
|
||||||
|
expect(orientedContainPageSize({ width: 500, height: 900 }, { width: 1200, height: 900 })).toEqual({ width: 500, height: 375 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unusable reader viewport measurements", () => {
|
||||||
|
expect(readableViewportSize({ width: 0, height: 833 })).toBeNull();
|
||||||
|
expect(readableViewportSize({ width: 1, height: 1 })).toBeNull();
|
||||||
|
expect(readableViewportSize({ width: 1548.8, height: 833.2 })).toEqual({ width: 1548, height: 833 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the minimal reader on one clamped page", () => {
|
||||||
|
expect(clampReaderPage(0, 12)).toBe(1);
|
||||||
|
expect(clampReaderPage(8, 12)).toBe(8);
|
||||||
|
expect(clampReaderPage(99, 12)).toBe(12);
|
||||||
|
expect(readerPositionLabel(8, 12)).toBe("Page 8 / 12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates page by page without leaving document bounds", () => {
|
||||||
|
expect(previousReaderPage(1, 12)).toBe(1);
|
||||||
|
expect(previousReaderPage(8, 12)).toBe(7);
|
||||||
|
expect(nextReaderPage(8, 12)).toBe(9);
|
||||||
|
expect(nextReaderPage(12, 12)).toBe(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -68,6 +68,13 @@ main {
|
|||||||
padding: 28px;
|
padding: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main.app-main-reader {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.page-grid {
|
.page-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@ -856,87 +863,82 @@ select {
|
|||||||
|
|
||||||
.reader-page {
|
.reader-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 14px;
|
padding: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #120e0b;
|
background: #120e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-page:fullscreen {
|
main.app-main-reader .reader-page {
|
||||||
width: 100vw;
|
height: 100%;
|
||||||
height: 100vh;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-topbar {
|
.reader-topbar {
|
||||||
position: relative;
|
grid-row: 1;
|
||||||
z-index: 5;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
padding: 10px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 8px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(38, 26, 18, 0.84);
|
background: rgba(38, 26, 18, 0.84);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-topbar div {
|
.reader-title {
|
||||||
display: grid;
|
flex: 1 1 auto;
|
||||||
justify-items: center;
|
overflow: hidden;
|
||||||
}
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
.reader-topbar span {
|
text-overflow: ellipsis;
|
||||||
color: var(--ink-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-toolbar .icon-only {
|
|
||||||
width: 40px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-toolbar .reader-mode-button {
|
|
||||||
gap: 7px;
|
|
||||||
padding-inline: 10px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-toolbar .active {
|
.reader-position {
|
||||||
border-color: rgba(213, 168, 77, 0.72);
|
flex: 0 0 auto;
|
||||||
color: var(--brass);
|
min-width: 92px;
|
||||||
background: rgba(213, 168, 77, 0.14);
|
color: var(--ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-topbar .ghost-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 32px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-status {
|
.reader-status {
|
||||||
position: relative;
|
grid-row: 2;
|
||||||
z-index: 4;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-stage {
|
.reader-stage {
|
||||||
|
grid-row: 3;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: stretch;
|
place-items: center;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-mode-vertical .reader-stage {
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-content {
|
.reader-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-reader,
|
.pdf-reader,
|
||||||
@ -950,19 +952,19 @@ select {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-reader-vertical,
|
|
||||||
.cbz-reader-vertical {
|
|
||||||
place-items: start center;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.epub-host {
|
.epub-host {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(100%, 980px);
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.epub-reader {
|
||||||
|
place-items: stretch;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.epub-view {
|
.epub-view {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -982,56 +984,23 @@ select {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page-frame canvas {
|
.pdf-page-frame canvas,
|
||||||
|
.cbz-reader img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: min(100%, 980px);
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: #f7f0df;
|
background: #f7f0df;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cbz-reader img {
|
.cbz-reader img {
|
||||||
display: block;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: min(100%, 980px);
|
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: #f7f0df;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-mode-vertical .pdf-reader,
|
|
||||||
.reader-mode-vertical .cbz-reader {
|
|
||||||
align-content: start;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-strip,
|
|
||||||
.comic-strip {
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 18px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-mode-vertical .pdf-page-frame canvas,
|
|
||||||
.reader-mode-vertical .cbz-reader img {
|
|
||||||
width: min(100%, 980px);
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-mode-horizontal .pdf-page-frame canvas,
|
|
||||||
.reader-mode-horizontal .cbz-reader img {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-fallback {
|
.reader-fallback {
|
||||||
@ -1044,71 +1013,6 @@ select {
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-stepper {
|
|
||||||
position: relative;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 46px;
|
|
||||||
padding: 6px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: rgba(38, 26, 18, 0.84);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-stepper span {
|
|
||||||
min-width: 92px;
|
|
||||||
color: var(--ink-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-button,
|
|
||||||
.reader-tap-zone {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 3;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-button {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 58px;
|
|
||||||
color: var(--ink);
|
|
||||||
opacity: 0.66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-button:hover {
|
|
||||||
opacity: 1;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-button:disabled,
|
|
||||||
.reader-tap-zone:disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-left,
|
|
||||||
.reader-tap-left {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-right,
|
|
||||||
.reader-tap-right {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-tap-zone {
|
|
||||||
display: none;
|
|
||||||
width: 34%;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-error {
|
.reader-error {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@ -1186,6 +1090,12 @@ select {
|
|||||||
padding: 16px 16px 86px;
|
padding: 16px 16px 86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main.app-main-reader {
|
||||||
|
height: calc(100vh - 58px);
|
||||||
|
padding: 0 0 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.page-grid {
|
.page-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -1251,71 +1161,7 @@ select {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-topbar div {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-topbar strong {
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 38vw;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-toolbar {
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-side-button {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-tap-zone {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-controls-hidden .reader-topbar,
|
|
||||||
.reader-controls-hidden .reader-status,
|
|
||||||
.reader-controls-hidden .reader-stepper {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-controls-hidden {
|
|
||||||
grid-template-rows: 0 0 minmax(0, 1fr) 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-controls-hidden .reader-topbar,
|
|
||||||
.reader-controls-hidden .reader-status,
|
|
||||||
.reader-controls-hidden .reader-stepper {
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-topbar,
|
|
||||||
.reader-status,
|
|
||||||
.reader-stepper {
|
|
||||||
transition: opacity 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-stepper {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-stepper .ghost-button {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.epub-host {
|
.epub-host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-mode-vertical .pdf-reader canvas,
|
|
||||||
.reader-mode-vertical .cbz-reader img {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user