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:
@ -40,6 +40,12 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /series {
|
||||||
|
proxy_pass http://api:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /progress {
|
location ^~ /progress {
|
||||||
proxy_pass http://api:3000;
|
proxy_pass http://api:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { BookDto } from "@readabook/shared";
|
|||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
import { CbzReader } from "../reader/CbzReader";
|
import { CbzReader } from "../reader/CbzReader";
|
||||||
import { EpubReader } from "../reader/EpubReader";
|
import { EpubReader } from "../reader/EpubReader";
|
||||||
import { 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 { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
@ -24,7 +24,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
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 [controlsVisible, setControlsVisible] = useState(true);
|
||||||
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
const { progress, saving, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||||
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
|
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||||
|
|
||||||
async function loadBook() {
|
async function loadBook() {
|
||||||
@ -58,20 +58,26 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
||||||
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||||
const savePdfPage = useCallback(
|
const savePdfPage = useCallback(
|
||||||
(nextPage: number, pages: number) => {
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
void save(`pdf:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
const locator = pageLocator("pdf", nextPage, anchor);
|
||||||
|
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||||
|
if (strategy === "queued") queueSave(locator, percent);
|
||||||
|
else void save(locator, percent);
|
||||||
},
|
},
|
||||||
[save]
|
[queueSave, save]
|
||||||
);
|
);
|
||||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||||
const saveComicPage = useCallback(
|
const saveComicPage = useCallback(
|
||||||
(nextPage: number, pages: number) => {
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
||||||
void save(`${prefix}:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
const locator = pageLocator(prefix, nextPage, anchor);
|
||||||
|
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||||
|
if (strategy === "queued") queueSave(locator, percent);
|
||||||
|
else void save(locator, percent);
|
||||||
},
|
},
|
||||||
[book?.format, save]
|
[book?.format, queueSave, save]
|
||||||
);
|
);
|
||||||
|
|
||||||
const readerError = error ?? progressError ?? preferencesError;
|
const readerError = error ?? progressError ?? preferencesError;
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import { useCallback, useEffect, 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, ReaderMode } from "../api/types";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
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({
|
export function CbzReader({
|
||||||
bookId,
|
bookId,
|
||||||
page,
|
page,
|
||||||
@ -13,22 +19,27 @@ export function CbzReader({
|
|||||||
bookId: number;
|
bookId: number;
|
||||||
page: number;
|
page: number;
|
||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
onPageCommit: (page: number, pages: number) => 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 restoredRef = useRef(false);
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const [visiblePage, setVisiblePage] = useState(page);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
setImageError(false);
|
||||||
|
restoredRef.current = false;
|
||||||
api
|
api
|
||||||
.cbzPages(bookId)
|
.cbzPages(bookId)
|
||||||
.then((nextPages) => {
|
.then((nextPages) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setPages(nextPages);
|
setPages(nextPages);
|
||||||
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount);
|
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (alive) setError("Archive CBZ indisponible.");
|
if (alive) setError("Archive CBZ indisponible.");
|
||||||
@ -40,34 +51,103 @@ export function CbzReader({
|
|||||||
|
|
||||||
const pageCount = pages?.pageCount ?? 1;
|
const pageCount = pages?.pageCount ?? 1;
|
||||||
const currentPage = Math.max(1, Math.min(page, pageCount));
|
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 currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
||||||
|
|
||||||
const go = useCallback((nextPage: number) => {
|
const go = useCallback(
|
||||||
|
(nextPage: number) => {
|
||||||
|
const target = Math.max(1, Math.min(nextPage, pageCount));
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
|
if (mode === "vertical") {
|
||||||
}, [onPageCommit, pageCount]);
|
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(() => {
|
useEffect(() => {
|
||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !error && !imageError && currentPage > 1,
|
canPrevious: !error && !imageError && displayedPage > 1,
|
||||||
canNext: !error && !imageError && currentPage < pageCount,
|
canNext: !error && !imageError && displayedPage < pageCount,
|
||||||
positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive",
|
canTop: !error && !imageError && displayedPage > 1,
|
||||||
onPrevious: () => go(currentPage - 1),
|
positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive",
|
||||||
onNext: () => go(currentPage + 1)
|
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]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={`cbz-reader cbz-reader-${mode}`}>
|
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
|
||||||
{error || imageError ? (
|
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>{error ?? "Page CBZ indisponible."}</span>
|
<span>{error ?? "Page CBZ indisponible."}</span>
|
||||||
<button className="ghost-button" onClick={() => go(currentPage)}>
|
<button className="ghost-button" onClick={() => go(currentPage)}>
|
||||||
Reessayer
|
Reessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
|
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||||
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
||||||
|
</figure>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,47 +1,177 @@
|
|||||||
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, readerErrorMessage } from "./ReaderError";
|
import { ReaderError } from "./ReaderError";
|
||||||
|
import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender";
|
||||||
import { configurePdfWorker } from "./pdfWorker";
|
import { configurePdfWorker } from "./pdfWorker";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
import type { ReaderMode } from "../api/types";
|
import type { ReaderMode } from "../api/types";
|
||||||
|
|
||||||
configurePdfWorker(pdfjs);
|
configurePdfWorker(pdfjs);
|
||||||
|
|
||||||
export function PdfReader({
|
type PageCommitStrategy = "immediate" | "queued";
|
||||||
url,
|
|
||||||
page,
|
type PdfReaderProps = {
|
||||||
backHref,
|
|
||||||
mode,
|
|
||||||
onPageCommit,
|
|
||||||
onControlsChange
|
|
||||||
}: {
|
|
||||||
url: string;
|
url: string;
|
||||||
page: number;
|
page: number;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
onPageCommit: (page: number, pages: number) => void;
|
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
type PdfPageCanvasProps = {
|
||||||
|
document: pdfjs.PDFDocumentProxy;
|
||||||
|
pageNumber: number;
|
||||||
|
mode: ReaderMode;
|
||||||
|
frameSize: { width: number; height: number };
|
||||||
|
onError: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pdfLifecycle = Promise.resolve();
|
||||||
|
|
||||||
|
function enqueuePdfLifecycle<T>(work: () => Promise<T>) {
|
||||||
|
const run = pdfLifecycle.then(work, work);
|
||||||
|
pdfLifecycle = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfTransitionError(error: unknown) {
|
||||||
|
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 {
|
||||||
|
return element?.closest(".reader-stage") as HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [visible, setVisible] = useState(mode === "horizontal");
|
||||||
|
const [rendered, setRendered] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "horizontal") {
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
|
||||||
|
},
|
||||||
|
{ root: scrollContainerFor(wrapper), rootMargin: "900px 0px" }
|
||||||
|
);
|
||||||
|
observer.observe(wrapper);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let renderTask: pdfjs.RenderTask | undefined;
|
||||||
|
setRendered(false);
|
||||||
|
|
||||||
|
async function renderPage() {
|
||||||
|
try {
|
||||||
|
const pdfPage = await document.getPage(pageNumber);
|
||||||
|
if (cancelled) return;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||||
|
const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height });
|
||||||
|
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio;
|
||||||
|
const viewport = pdfPage.getViewport({ scale: renderScale });
|
||||||
|
const cssWidth = Math.floor(viewport.width / pixelRatio);
|
||||||
|
const cssHeight = Math.floor(viewport.height / pixelRatio);
|
||||||
|
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||||
|
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||||
|
canvas.style.width = `${cssWidth}px`;
|
||||||
|
canvas.style.height = `${cssHeight}px`;
|
||||||
|
const context = canvas.getContext("2d", { alpha: false });
|
||||||
|
if (!context) return;
|
||||||
|
context.fillStyle = "#f7f0df";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||||
|
await renderTask.promise;
|
||||||
|
if (!pdfCanvasHasVisibleInk(context.getImageData(0, 0, canvas.width, canvas.height))) {
|
||||||
|
throw new Error(`La page PDF ${pageNumber} est blanche apres rendu.`);
|
||||||
|
}
|
||||||
|
if (!cancelled) setRendered(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void enqueuePdfLifecycle(renderPage);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
renderTask?.cancel();
|
||||||
|
};
|
||||||
|
}, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pdf-page-frame" data-reader-page={pageNumber} ref={wrapperRef}>
|
||||||
|
{!rendered && (
|
||||||
|
<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 frameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
|
const [visiblePage, setVisiblePage] = useState(page);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const [frameSize, setFrameSize] = useState({ width: 980, height: 900 });
|
const [frameSize, setFrameSize] = useState({ width: 980, height: 900 });
|
||||||
|
|
||||||
const currentPage = Math.max(1, Math.min(page, pages));
|
const currentPage = Math.max(1, Math.min(page, pages));
|
||||||
const go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, 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(() => {
|
useEffect(() => {
|
||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !loading && !error && currentPage > 1,
|
canPrevious: !loading && !error && displayedPage > 1,
|
||||||
canNext: !loading && !error && currentPage < pages,
|
canNext: !loading && !error && displayedPage < pages,
|
||||||
positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`,
|
canTop: !loading && !error && displayedPage > 1,
|
||||||
onPrevious: () => go(currentPage - 1),
|
positionLabel: loading ? "Ouverture PDF" : `${displayedPage} / ${pages}`,
|
||||||
onNext: () => go(currentPage + 1)
|
onPrevious: () => go(displayedPage - 1),
|
||||||
|
onNext: () => go(displayedPage + 1),
|
||||||
|
onTop: goTop
|
||||||
});
|
});
|
||||||
}, [currentPage, error, go, loading, onControlsChange, pages]);
|
}, [displayedPage, error, go, goTop, loading, onControlsChange, pages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = frameRef.current;
|
const frame = frameRef.current;
|
||||||
@ -59,70 +189,95 @@ export function PdfReader({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
||||||
let renderTask: pdfjs.RenderTask | undefined;
|
|
||||||
async function render() {
|
|
||||||
try {
|
|
||||||
configurePdfWorker(pdfjs);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
setDocumentProxy(null);
|
||||||
|
restoredRef.current = false;
|
||||||
|
|
||||||
|
async function loadDocument() {
|
||||||
|
try {
|
||||||
|
configurePdfWorker(pdfjs);
|
||||||
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||||
const document = await loadingTask.promise;
|
const loadedDocument = await loadingTask.promise;
|
||||||
if (cancelled) return;
|
if (cancelled) {
|
||||||
setPages(document.numPages);
|
await loadingTask.destroy();
|
||||||
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
|
return;
|
||||||
const canvas = canvasRef.current;
|
}
|
||||||
if (!canvas) return;
|
setPages(loadedDocument.numPages);
|
||||||
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
setDocumentProxy(loadedDocument);
|
||||||
const fitScale =
|
setLoading(false);
|
||||||
mode === "vertical"
|
} catch (loadError) {
|
||||||
? frameSize.width / baseViewport.width
|
if (cancelled && isPdfTransitionError(loadError)) return;
|
||||||
: Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height);
|
|
||||||
const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1);
|
|
||||||
const viewport = pdfPage.getViewport({ scale: renderScale });
|
|
||||||
canvas.width = Math.floor(viewport.width);
|
|
||||||
canvas.height = Math.floor(viewport.height);
|
|
||||||
canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`;
|
|
||||||
canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return;
|
|
||||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
|
||||||
await renderTask.promise;
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
} catch (renderError) {
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(readerErrorMessage(renderError, "PDF indisponible"));
|
setError(pdfReaderErrorMessage(loadError));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void render();
|
|
||||||
|
void enqueuePdfLifecycle(loadDocument);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
renderTask?.cancel();
|
|
||||||
void loadingTask?.destroy();
|
void loadingTask?.destroy();
|
||||||
};
|
};
|
||||||
}, [url, page, attempt, frameSize.height, frameSize.width, mode]);
|
}, [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 (
|
return (
|
||||||
<div className="pdf-reader" ref={frameRef}>
|
<div className={`pdf-reader pdf-reader-${mode}`} ref={frameRef}>
|
||||||
{error ? (
|
{error ? (
|
||||||
<ReaderError
|
<ReaderError
|
||||||
title="Lecture PDF indisponible"
|
title="Lecture PDF indisponible"
|
||||||
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
detail={error}
|
||||||
technicalDetail={error}
|
|
||||||
downloadUrl={url}
|
downloadUrl={url}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
onRetry={() => setAttempt((value) => value + 1)}
|
onRetry={() => setAttempt((value) => value + 1)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : loading || !documentProxy ? (
|
||||||
<>
|
|
||||||
{loading && (
|
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Ouverture PDF</span>
|
<span>Ouverture PDF</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : mode === "vertical" ? (
|
||||||
<canvas ref={canvasRef} />
|
<div className="pdf-strip">
|
||||||
</>
|
{Array.from({ length: pages }, (_, index) => (
|
||||||
|
<PdfPageCanvas key={index + 1} document={documentProxy} pageNumber={index + 1} mode={mode} frameSize={frameSize} onError={handlePageError} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PdfPageCanvas document={documentProxy} pageNumber={currentPage} mode={mode} frameSize={frameSize} onError={handlePageError} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react";
|
import { ArrowLeft, ArrowRight, ArrowUp, Columns2, Maximize2, Minimize2, RotateCcw, Rows3, Save } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { ErrorRibbon, Meter } from "../components/ui";
|
import { ErrorRibbon, Meter } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import type { ReaderMode } from "../api/types";
|
import type { ReaderMode } from "../api/types";
|
||||||
@ -7,9 +7,11 @@ 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 = {
|
||||||
@ -41,6 +43,20 @@ export function ReaderShell({
|
|||||||
onToggleControls,
|
onToggleControls,
|
||||||
children
|
children
|
||||||
}: ReaderShellProps) {
|
}: 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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||||
|
updateFullscreen();
|
||||||
|
document.addEventListener("fullscreenchange", updateFullscreen);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", updateFullscreen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
|
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
|
||||||
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
|
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
|
||||||
@ -54,20 +70,25 @@ export function ReaderShell({
|
|||||||
</div>
|
</div>
|
||||||
<div className="reader-toolbar">
|
<div className="reader-toolbar">
|
||||||
<button
|
<button
|
||||||
className={`ghost-button icon-only ${mode === "horizontal" ? "active" : ""}`}
|
className={`ghost-button reader-mode-button ${mode === "horizontal" ? "active" : ""}`}
|
||||||
onClick={() => onModeChange("horizontal")}
|
onClick={() => onModeChange("horizontal")}
|
||||||
aria-label="Lecture horizontale"
|
aria-label="Lecture horizontale"
|
||||||
title="Lecture horizontale"
|
title="Lecture horizontale"
|
||||||
>
|
>
|
||||||
<Columns2 size={18} />
|
<Columns2 size={18} />
|
||||||
|
Horizontal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`ghost-button icon-only ${mode === "vertical" ? "active" : ""}`}
|
className={`ghost-button reader-mode-button ${mode === "vertical" ? "active" : ""}`}
|
||||||
onClick={() => onModeChange("vertical")}
|
onClick={() => onModeChange("vertical")}
|
||||||
aria-label="Lecture verticale"
|
aria-label="Lecture verticale"
|
||||||
title="Lecture verticale"
|
title="Lecture verticale"
|
||||||
>
|
>
|
||||||
<Rows3 size={18} />
|
<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>
|
</button>
|
||||||
{error && onRetry ? (
|
{error && onRetry ? (
|
||||||
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
|
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
|
||||||
@ -130,15 +151,29 @@ export function ReaderShell({
|
|||||||
<div className="reader-content">{children}</div>
|
<div className="reader-content">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
|
<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}>
|
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Precedent
|
Precedent
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<span>{controls.positionLabel}</span>
|
<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}>
|
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
|
||||||
Suivant
|
Suivant
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePageAnchor, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||||
|
|
||||||
describe("reader locators", () => {
|
describe("reader locators", () => {
|
||||||
it("parses valid PDF page locators", () => {
|
it("parses valid PDF page locators", () => {
|
||||||
expect(parsePdfPageLocator("pdf:page:12")).toBe(12);
|
expect(parsePdfPageLocator("pdf:page:12")).toBe(12);
|
||||||
|
expect(parsePdfPageLocator("pdf:page:12@0.250")).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores invalid PDF locators", () => {
|
it("ignores invalid PDF locators", () => {
|
||||||
@ -23,7 +24,15 @@ describe("reader locators", () => {
|
|||||||
|
|
||||||
it("bounds PDF page percentages", () => {
|
it("bounds PDF page percentages", () => {
|
||||||
expect(pdfPagePercent(2, 4)).toBe(50);
|
expect(pdfPagePercent(2, 4)).toBe(50);
|
||||||
|
expect(pdfPagePercent(2, 4, 0.25)).toBe(31);
|
||||||
expect(pdfPagePercent(8, 4)).toBe(100);
|
expect(pdfPagePercent(8, 4)).toBe(100);
|
||||||
expect(pdfPagePercent(1, 0)).toBe(0);
|
expect(pdfPagePercent(1, 0)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes page locators with relative anchors", () => {
|
||||||
|
expect(pageLocator("pdf", 3)).toBe("pdf:page:3");
|
||||||
|
expect(pageLocator("cbz", 3, 0.25)).toBe("cbz:page:3@0.250");
|
||||||
|
expect(parsePageAnchor("cbr:page:8@0.750")).toBe(0.75);
|
||||||
|
expect(parsePageAnchor("cbr:page:8@2")).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,11 +14,26 @@ export function parseCbrPageLocator(locator?: string | null): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePageSuffix(locator: string): number | null {
|
function parsePageSuffix(locator: string): number | null {
|
||||||
const value = Number(locator.split(":").at(-1));
|
const value = Number(locator.split(":").at(-1)?.split("@")[0]);
|
||||||
return Number.isInteger(value) && value > 0 ? value : null;
|
return Number.isInteger(value) && value > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pdfPagePercent(page: number, pages: number): number {
|
export function parsePageAnchor(locator?: string | null): number | null {
|
||||||
if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0;
|
const rawAnchor = locator?.split("@")[1];
|
||||||
return Math.max(0, Math.min(100, Math.round((page / pages) * 100)));
|
if (!rawAnchor) return null;
|
||||||
|
const anchor = Number(rawAnchor);
|
||||||
|
return Number.isFinite(anchor) ? Math.max(0, Math.min(1, anchor)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageLocator(prefix: "pdf" | "cbz" | "cbr", page: number, anchor = 1): string {
|
||||||
|
const normalizedPage = Number.isInteger(page) && page > 0 ? page : 1;
|
||||||
|
const normalizedAnchor = Math.max(0, Math.min(1, anchor));
|
||||||
|
if (normalizedAnchor >= 0.995) return `${prefix}:page:${normalizedPage}`;
|
||||||
|
return `${prefix}:page:${normalizedPage}@${normalizedAnchor.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfPagePercent(page: number, pages: number, anchor = 1): number {
|
||||||
|
if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0;
|
||||||
|
const normalizedAnchor = Math.max(0, Math.min(1, anchor));
|
||||||
|
return Math.max(0, Math.min(100, Math.round(((page - 1 + normalizedAnchor) / pages) * 100)));
|
||||||
}
|
}
|
||||||
|
|||||||
34
apps/web/src/reader/pdfRender.ts
Normal file
34
apps/web/src/reader/pdfRender.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { ReaderMode } from "../api/types";
|
||||||
|
|
||||||
|
const PDF_RENDER_BACKGROUND = { red: 247, green: 240, blue: 223 };
|
||||||
|
const PDF_BLANK_THRESHOLD = 245;
|
||||||
|
const PDF_BACKGROUND_TOLERANCE = 4;
|
||||||
|
|
||||||
|
export function pdfRenderScale(mode: ReaderMode, frameSize: { width: number; height: number }, pageSize: { width: number; height: number }) {
|
||||||
|
if (pageSize.width <= 0 || pageSize.height <= 0) return 1;
|
||||||
|
const fitWidth = frameSize.width / pageSize.width;
|
||||||
|
if (mode === "vertical") return Math.min(1.65, fitWidth);
|
||||||
|
return Math.min(fitWidth, frameSize.height / pageSize.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearRenderBackground(red: number, green: number, blue: number) {
|
||||||
|
return (
|
||||||
|
Math.abs(red - PDF_RENDER_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE &&
|
||||||
|
Math.abs(green - PDF_RENDER_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE &&
|
||||||
|
Math.abs(blue - PDF_RENDER_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfCanvasHasVisibleInk(imageData: Pick<ImageData, "data">) {
|
||||||
|
const data = imageData.data;
|
||||||
|
for (let index = 0; index < data.length; index += 4) {
|
||||||
|
const red = data[index] ?? 255;
|
||||||
|
const green = data[index + 1] ?? 255;
|
||||||
|
const blue = data[index + 2] ?? 255;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
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 { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||||
import { readerErrorMessage } from "./ReaderError";
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
@ -30,4 +31,14 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||||
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("separates PDF fit-page and fit-width scales", () => {
|
||||||
|
expect(pdfRenderScale("horizontal", { 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects blank PDF canvas renders", () => {
|
||||||
|
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 247, 240, 223, 255]) })).toBe(false);
|
||||||
|
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 32, 28, 24, 255]) })).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ProgressDto } from "@readabook/shared";
|
import type { ProgressDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
|
|
||||||
|
type PendingProgress = {
|
||||||
|
locator: string;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function useReaderProgress(bookId: number) {
|
export function useReaderProgress(bookId: number) {
|
||||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const pendingRef = useRef<PendingProgress | null>(null);
|
||||||
|
const saveTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@ -24,6 +31,8 @@ export function useReaderProgress(bookId: number) {
|
|||||||
|
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
async (locator: string, percent: number) => {
|
async (locator: string, percent: number) => {
|
||||||
|
pendingRef.current = null;
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
@ -44,5 +53,34 @@ export function useReaderProgress(bookId: number) {
|
|||||||
[bookId]
|
[bookId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { progress, saving, error, save };
|
const flush = useCallback(() => {
|
||||||
|
const pending = pendingRef.current;
|
||||||
|
if (!pending) return;
|
||||||
|
void save(pending.locator, pending.percent);
|
||||||
|
}, [save]);
|
||||||
|
|
||||||
|
const queueSave = useCallback(
|
||||||
|
(locator: string, percent: number) => {
|
||||||
|
pendingRef.current = { locator, percent };
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = window.setTimeout(flush, 2000);
|
||||||
|
},
|
||||||
|
[flush]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flushOnHidden = () => {
|
||||||
|
if (document.visibilityState === "hidden") flush();
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", flush);
|
||||||
|
document.addEventListener("visibilitychange", flushOnHidden);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", flush);
|
||||||
|
document.removeEventListener("visibilitychange", flushOnHidden);
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
flush();
|
||||||
|
};
|
||||||
|
}, [flush]);
|
||||||
|
|
||||||
|
return { progress, saving, error, save, queueSave, flush };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
"/auth": "http://127.0.0.1:3000",
|
"/auth": "http://127.0.0.1:3000",
|
||||||
"/admin": "http://127.0.0.1:3000",
|
"/admin": "http://127.0.0.1:3000",
|
||||||
"/books": "http://127.0.0.1:3000",
|
"/books": "http://127.0.0.1:3000",
|
||||||
|
"/series": "http://127.0.0.1:3000",
|
||||||
"/progress": "http://127.0.0.1:3000",
|
"/progress": "http://127.0.0.1:3000",
|
||||||
"/healthz": "http://127.0.0.1:3000"
|
"/healthz": "http://127.0.0.1:3000"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user