fix(web,api): lecteur — worker pdf.js dédié, shell commun et préférences par livre

Régression worker PDF : le worker pdf.js est désormais instancié une
seule fois via un port dédié (?worker&url) et reconfiguré à chaque
montage, au lieu d'un workerSrc recalculé qui cassait le rendu.

- ReaderShell : chrome commun aux lecteurs (toolbar, zones de tap,
  statut) et contrat ReaderControls pour EPUB/PDF/CBZ
- préférences de lecture par livre (mode horizontal/vertical, fit) :
  table reader_preferences + migrations idempotentes, module API,
  DTO partagés, client web avec fallback localStorage hors-ligne

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 18:06:22 +02:00
parent 8024cab11c
commit 5de46a6f6d
19 changed files with 790 additions and 89 deletions

View File

@ -1,15 +1,20 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../api/client";
import type { CbzPagesDto } from "../api/types";
import type { CbzPagesDto, ReaderMode } from "../api/types";
import type { ReaderControls } from "./ReaderShell";
export function CbzReader({
bookId,
page,
onPageCommit
mode,
onPageCommit,
onControlsChange
}: {
bookId: number;
page: number;
mode: ReaderMode;
onPageCommit: (page: number, pages: number) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [error, setError] = useState<string>();
@ -37,13 +42,23 @@ export function CbzReader({
const currentPage = Math.max(1, Math.min(page, pageCount));
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
function go(nextPage: number) {
const go = useCallback((nextPage: number) => {
setImageError(false);
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
}
}, [onPageCommit, pageCount]);
useEffect(() => {
onControlsChange({
canPrevious: !error && !imageError && currentPage > 1,
canNext: !error && !imageError && currentPage < pageCount,
positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive",
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
}, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]);
return (
<div className="cbz-reader">
<div className={`cbz-reader cbz-reader-${mode}`}>
{error || imageError ? (
<div className="reader-fallback">
<span>{error ?? "Page CBZ indisponible."}</span>
@ -54,17 +69,6 @@ export function CbzReader({
) : (
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
)}
<div className="reader-stepper">
<button className="ghost-button" onClick={() => go(currentPage - 1)}>
Precedent
</button>
<span>
{currentPage} / {pageCount}
</span>
<button className="ghost-button" onClick={() => go(currentPage + 1)}>
Suivant
</button>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
type FoliateLocation = {
cfi?: string;
@ -42,12 +43,16 @@ export function EpubReader({
url,
locator,
backHref,
onLocatorChange
mode,
onLocatorChange,
onControlsChange
}: {
url: string;
locator?: string;
backHref: string;
mode: ReaderMode;
onLocatorChange: (locator: string, percent: number) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
@ -56,6 +61,16 @@ export function EpubReader({
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
onControlsChange({
canPrevious: !loading && !error,
canNext: !loading && !error,
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
onPrevious: () => void viewRef.current?.goLeft(),
onNext: () => void viewRef.current?.goRight()
});
}, [error, loading, onControlsChange]);
useEffect(() => {
locatorRef.current = locator;
}, [locator]);
@ -108,6 +123,11 @@ export function EpubReader({
};
}, [attempt, onLocatorChange, url]);
useEffect(() => {
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal");
}, [mode]);
if (error) {
return (
<div className="epub-reader">
@ -124,24 +144,13 @@ export function EpubReader({
}
return (
<div className="epub-reader">
<div className={`epub-reader epub-reader-${mode}`}>
{loading && (
<div className="reader-fallback">
<span>Ouverture EPUB</span>
</div>
)}
<div className="epub-host" ref={hostRef} />
<div className="reader-stepper">
<button className="ghost-button" onClick={() => void viewRef.current?.goLeft()} disabled={loading}>
<ArrowLeft size={16} />
Précédent
</button>
<span>{loading ? "Chargement" : "Lecture intégrée"}</span>
<button className="ghost-button" onClick={() => void viewRef.current?.goRight()} disabled={loading}>
Suivant
<ArrowRight size={16} />
</button>
</div>
</div>
);
}

View File

@ -1,26 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import { pdfWorkerSrc } from "./pdfWorker";
import { configurePdfWorker } from "./pdfWorker";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
configurePdfWorker(pdfjs);
export function PdfReader({
url,
page,
backHref,
onPageCommit
mode,
onPageCommit,
onControlsChange
}: {
url: string;
page: number;
backHref: string;
mode: ReaderMode;
onPageCommit: (page: number, pages: number) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const [pages, setPages] = useState(1);
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 go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]);
useEffect(() => {
onControlsChange({
canPrevious: !loading && !error && currentPage > 1,
canNext: !loading && !error && currentPage < pages,
positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`,
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
}, [currentPage, error, go, 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;
@ -28,6 +62,7 @@ export function PdfReader({
let renderTask: pdfjs.RenderTask | undefined;
async function render() {
try {
configurePdfWorker(pdfjs);
setLoading(true);
setError(undefined);
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
@ -37,9 +72,17 @@ export function PdfReader({
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
const canvas = canvasRef.current;
if (!canvas) return;
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) });
canvas.width = viewport.width;
canvas.height = viewport.height;
const baseViewport = pdfPage.getViewport({ scale: 1 });
const fitScale =
mode === "vertical"
? frameSize.width / baseViewport.width
: 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 });
@ -58,10 +101,10 @@ export function PdfReader({
renderTask?.cancel();
void loadingTask?.destroy();
};
}, [url, page, attempt]);
}, [url, page, attempt, frameSize.height, frameSize.width, mode]);
return (
<div className="pdf-reader">
<div className="pdf-reader" ref={frameRef}>
{error ? (
<ReaderError
title="Lecture PDF indisponible"
@ -81,17 +124,6 @@ export function PdfReader({
<canvas ref={canvasRef} />
</>
)}
<div className="reader-stepper">
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
Précédent
</button>
<span>
{page} / {pages}
</span>
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)} disabled={Boolean(error) || loading}>
Suivant
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react";
import type { ReactNode } from "react";
import { ErrorRibbon, Meter } from "../components/ui";
import { navigate } from "../router";
import type { ReaderMode } from "../api/types";
export type ReaderControls = {
canPrevious: boolean;
canNext: boolean;
positionLabel: string;
onPrevious: () => void;
onNext: () => void;
};
type ReaderShellProps = {
title: string;
status: string;
backHref: string;
progress: number;
error?: string;
onRetry?: () => void;
mode: ReaderMode;
onModeChange: (mode: ReaderMode) => void;
controls: ReaderControls;
controlsVisible: boolean;
onToggleControls: () => void;
children: ReactNode;
};
export function ReaderShell({
title,
status,
backHref,
progress,
error,
onRetry,
mode,
onModeChange,
controls,
controlsVisible,
onToggleControls,
children
}: ReaderShellProps) {
return (
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
<button className="ghost-button" onClick={() => navigate(backHref)}>
<ArrowLeft size={17} />
Fiche
</button>
<div>
<strong>{title}</strong>
<span>{status}</span>
</div>
<div className="reader-toolbar">
<button
className={`ghost-button icon-only ${mode === "horizontal" ? "active" : ""}`}
onClick={() => onModeChange("horizontal")}
aria-label="Lecture horizontale"
title="Lecture horizontale"
>
<Columns2 size={18} />
</button>
<button
className={`ghost-button icon-only ${mode === "vertical" ? "active" : ""}`}
onClick={() => onModeChange("vertical")}
aria-label="Lecture verticale"
title="Lecture verticale"
>
<Rows3 size={18} />
</button>
{error && onRetry ? (
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
<RotateCcw size={18} />
</button>
) : (
<Save size={18} />
)}
</div>
</header>
<div className="reader-status" onClick={(event) => event.stopPropagation()}>
<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>
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
<ArrowLeft size={16} />
Precedent
</button>
<span>{controls.positionLabel}</span>
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
Suivant
<ArrowRight size={16} />
</button>
</footer>
</div>
);
}

View File

@ -1 +1,17 @@
export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?worker&url";
export const pdfWorkerSrc = pdfWorkerUrl;
let pdfWorkerPort: Worker | null = null;
export function configurePdfWorker(pdfjs: Pick<typeof import("pdfjs-dist"), "GlobalWorkerOptions">) {
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
if (typeof window === "undefined" || !("Worker" in window)) {
return false;
}
pdfWorkerPort ??= new Worker(pdfWorkerSrc, { type: "module" });
pdfjs.GlobalWorkerOptions.workerPort = pdfWorkerPort;
return true;
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfWorkerSrc } from "./pdfWorker";
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
import { readerErrorMessage } from "./ReaderError";
describe("reader runtime helpers", () => {
@ -9,10 +9,23 @@ describe("reader runtime helpers", () => {
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
});
it("keeps PDF.js worker source on the bundled module worker", () => {
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
});
it("configures the PDF.js worker fallback without creating a worker outside the browser", () => {
const pdfjs = {
GlobalWorkerOptions: {
workerPort: null,
workerSrc: ""
}
};
expect(configurePdfWorker(pdfjs as unknown as Parameters<typeof configurePdfWorker>[0])).toBe(false);
expect(pdfjs.GlobalWorkerOptions.workerPort).toBeNull();
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc);
});
it("normalizes reader technical errors", () => {
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");

View File

@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../api/client";
import type { ReaderMode, ReaderPreferencesDto } from "../api/types";
const defaultPreferences: ReaderPreferencesDto = { mode: "horizontal", fit: "page" };
export function useReaderPreferences(bookId: number) {
const [preferences, setPreferences] = useState<ReaderPreferencesDto>(defaultPreferences);
const [error, setError] = useState<string>();
useEffect(() => {
let alive = true;
setError(undefined);
api
.readerPreferences(bookId)
.then((next) => {
if (alive) setPreferences(next);
})
.catch(() => {
if (alive) setError("Preferences lecteur conservees sur cet appareil.");
});
return () => {
alive = false;
};
}, [bookId]);
const setMode = useCallback(
(mode: ReaderMode) => {
const next = { ...preferences, mode };
setPreferences(next);
void api.saveReaderPreferences(bookId, next).catch(() => setError("Preferences lecteur conservees sur cet appareil."));
},
[bookId, preferences]
);
return { preferences, setMode, error };
}