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:
@ -51,6 +51,22 @@ describe("api fallback helpers", () => {
|
||||
await expect(api.scanLibrary(42)).rejects.toThrow("offline");
|
||||
});
|
||||
|
||||
it("keeps reader preferences locally when the backend contract is absent", async () => {
|
||||
const storage = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
clear: () => storage.clear()
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" });
|
||||
await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" });
|
||||
expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" }));
|
||||
});
|
||||
|
||||
it("sends metadata source updates to the admin endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {
|
||||
|
||||
@ -26,9 +26,10 @@ import {
|
||||
mockProgress,
|
||||
mockUser
|
||||
} from "./mockData";
|
||||
import type { CbzPagesDto, ContinueItem, Session } from "./types";
|
||||
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
fallback?: unknown;
|
||||
@ -111,6 +112,30 @@ function queryString(query: Partial<BookQueryDto>): string {
|
||||
return value ? `?${value}` : "";
|
||||
}
|
||||
|
||||
function readerPreferencesKey(bookId: number): string {
|
||||
return `${READER_PREFERENCES_PREFIX}${bookId}`;
|
||||
}
|
||||
|
||||
function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto {
|
||||
if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" };
|
||||
const raw = localStorage.getItem(readerPreferencesKey(bookId));
|
||||
if (!raw) return { mode: "horizontal", fit: "page" };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ReaderPreferencesDto>;
|
||||
return {
|
||||
mode: parsed.mode === "vertical" ? "vertical" : "horizontal",
|
||||
fit: parsed.fit === "width" ? "width" : "page"
|
||||
};
|
||||
} catch {
|
||||
return { mode: "horizontal", fit: "page" };
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async session(): Promise<Session> {
|
||||
try {
|
||||
@ -176,6 +201,31 @@ export const api = {
|
||||
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
||||
});
|
||||
},
|
||||
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
|
||||
try {
|
||||
const preferences = await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
|
||||
fallback: readLocalReaderPreferences(bookId)
|
||||
});
|
||||
writeLocalReaderPreferences(bookId, preferences);
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
|
||||
return readLocalReaderPreferences(bookId);
|
||||
}
|
||||
},
|
||||
async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise<ReaderPreferencesDto> {
|
||||
writeLocalReaderPreferences(bookId, input);
|
||||
try {
|
||||
return await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: input
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
|
||||
return input;
|
||||
}
|
||||
},
|
||||
async continueReading(): Promise<ContinueItem[]> {
|
||||
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
|
||||
},
|
||||
|
||||
@ -27,3 +27,12 @@ export type CbzPagesDto = {
|
||||
pageCount: number;
|
||||
pages: Array<{ page: number; name: string }>;
|
||||
};
|
||||
|
||||
export type ReaderMode = "horizontal" | "vertical";
|
||||
|
||||
export type ReaderFit = "page" | "width";
|
||||
|
||||
export type ReaderPreferencesDto = {
|
||||
mode: ReaderMode;
|
||||
fit?: ReaderFit;
|
||||
};
|
||||
|
||||
@ -1,21 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, RotateCcw, Save } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { ErrorRibbon, Meter } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
import { CbzReader } from "../reader/CbzReader";
|
||||
import { EpubReader } from "../reader/EpubReader";
|
||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { PdfReader } from "../reader/PdfReader";
|
||||
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
|
||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||
|
||||
const idleControls: ReaderControls = {
|
||||
canPrevious: false,
|
||||
canNext: false,
|
||||
positionLabel: "Chargement",
|
||||
onPrevious: () => undefined,
|
||||
onNext: () => undefined
|
||||
};
|
||||
|
||||
export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const [book, setBook] = useState<BookDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [page, setPage] = useState(1);
|
||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
||||
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||
|
||||
async function loadBook() {
|
||||
setLoading(true);
|
||||
@ -35,6 +45,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
void loadBook();
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
setReaderControls(idleControls);
|
||||
setControlsVisible(true);
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||
@ -59,27 +74,22 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
[book?.format, save]
|
||||
);
|
||||
|
||||
const readerError = error ?? progressError ?? preferencesError;
|
||||
|
||||
return (
|
||||
<div className="reader-page">
|
||||
<header className="reader-topbar">
|
||||
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||
<ArrowLeft size={17} />
|
||||
Fiche
|
||||
</button>
|
||||
<div>
|
||||
<strong>{book?.title ?? "Ouverture du lecteur"}</strong>
|
||||
<span>{loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<button className="ghost-button icon-only" onClick={() => void loadBook()} aria-label="Reessayer">
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<Save size={18} />
|
||||
)}
|
||||
</header>
|
||||
<ErrorRibbon message={error ?? progressError} />
|
||||
<Meter value={progress?.percent ?? 0} />
|
||||
<ReaderShell
|
||||
title={book?.title ?? "Ouverture du lecteur"}
|
||||
status={loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}
|
||||
backHref={backHref}
|
||||
progress={progress?.percent ?? 0}
|
||||
error={readerError}
|
||||
onRetry={error ? () => void loadBook() : undefined}
|
||||
mode={preferences.mode}
|
||||
onModeChange={setMode}
|
||||
controls={readerControls}
|
||||
controlsVisible={controlsVisible}
|
||||
onToggleControls={() => setControlsVisible((visible) => !visible)}
|
||||
>
|
||||
{!book ? (
|
||||
<div className="reader-fallback">
|
||||
<span>{error ?? "Chargement du livre."}</span>
|
||||
@ -88,12 +98,32 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
</button>
|
||||
</div>
|
||||
) : book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} backHref={backHref} onPageCommit={savePdfPage} />
|
||||
<PdfReader
|
||||
url={fileUrl}
|
||||
page={page}
|
||||
backHref={backHref}
|
||||
mode={preferences.mode}
|
||||
onPageCommit={savePdfPage}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
||||
<CbzReader
|
||||
bookId={book.id}
|
||||
page={page}
|
||||
mode={preferences.mode}
|
||||
onPageCommit={saveComicPage}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
) : (
|
||||
<EpubReader url={fileUrl} locator={progress?.locator} backHref={backHref} onLocatorChange={saveEpubLocator} />
|
||||
<EpubReader
|
||||
url={fileUrl}
|
||||
locator={progress?.locator}
|
||||
backHref={backHref}
|
||||
mode={preferences.mode}
|
||||
onLocatorChange={saveEpubLocator}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ReaderShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
145
apps/web/src/reader/ReaderShell.tsx
Normal file
145
apps/web/src/reader/ReaderShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
37
apps/web/src/reader/useReaderPreferences.ts
Normal file
37
apps/web/src/reader/useReaderPreferences.ts
Normal 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 };
|
||||
}
|
||||
@ -643,13 +643,18 @@ select {
|
||||
|
||||
.reader-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
background: #120e0b;
|
||||
}
|
||||
|
||||
.reader-topbar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
@ -667,20 +672,65 @@ select {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.reader-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reader-toolbar .icon-only {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader-toolbar .active {
|
||||
border-color: rgba(213, 168, 77, 0.72);
|
||||
color: var(--brass);
|
||||
background: rgba(213, 168, 77, 0.14);
|
||||
}
|
||||
|
||||
.reader-status {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.reader-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: stretch;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .reader-stage {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.reader-content {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdf-reader,
|
||||
.epub-reader,
|
||||
.cbz-reader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
min-height: calc(100vh - 120px);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.epub-host {
|
||||
display: grid;
|
||||
width: min(100%, 980px);
|
||||
height: calc(100vh - 170px);
|
||||
min-height: 460px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.epub-view {
|
||||
@ -694,8 +744,9 @@ select {
|
||||
|
||||
.pdf-reader canvas,
|
||||
.cbz-reader img {
|
||||
display: block;
|
||||
max-width: min(100%, 980px);
|
||||
max-height: calc(100vh - 170px);
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f7f0df;
|
||||
@ -706,6 +757,26 @@ select {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-reader,
|
||||
.reader-mode-vertical .cbz-reader {
|
||||
align-content: start;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-reader canvas,
|
||||
.reader-mode-vertical .cbz-reader img {
|
||||
width: min(100%, 980px);
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.reader-mode-horizontal .pdf-reader canvas,
|
||||
.reader-mode-horizontal .cbz-reader img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.reader-fallback {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -717,9 +788,68 @@ select {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@ -854,4 +984,80 @@ select {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.reader-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.reader-topbar {
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-reader canvas,
|
||||
.reader-mode-vertical .cbz-reader img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user