Compare commits

...

20 Commits

Author SHA1 Message Date
c17ea105a9 merge: develop dans main — release v1.0.0 (lecteur PDF/EPUB/CBZ avec zoom/pinch/modes, accueil refondu, pages d'erreur, profil clarifié — tickets #34 à #48) 2026-08-28 23:56:17 +02:00
af8ebef275 merge: feature/48-home-profile-polish dans develop (accueil allégé, grilles pleine largeur à 5 colonnes, bibliothèques scrollables, profil clarifié — évolution #48) 2026-08-28 23:53:15 +02:00
1bbf03d875 feat(web): accueil et profil — allègement du hero (textes descriptifs retirés), grilles de livres pleine largeur à 5 colonnes (auto-fit sans max-width), liste des bibliothèques scrollable (3 lignes visibles, partagée avec .continue-grid), et écran de profil clarifié (avatar, bloc identité, actions Modifier/Sortir — évolution #48) 2026-08-28 23:53:11 +02:00
8d5d1bc2ff merge: fix/47-home-continue-meter dans develop (barre d'avancement rétablie dans les livres en cours de l'accueil — correctif #47) 2026-08-28 23:36:15 +02:00
72f43c629d fix(web): accueil — rétablir la barre d'avancement dans les livres en cours (Meter avec progressPercent dans renderHomeBook, tuiles « en cours » pointant de nouveau vers le lecteur — correctif bug #47, régression de l'évolution #45) 2026-08-28 23:36:13 +02:00
28ee33bd0b merge: feature/46-home-grid-error-pages dans develop (grilles limitées à 5 colonnes, bandeau API retiré, pages d'erreur pour livres inexistants — évolution #46) 2026-08-28 23:29:08 +02:00
c2cdb07f9a feat(web): bibliothèque et lecteur — grilles de livres bornées à 5 colonnes (max-width calc sur .book-grid et .home-book-grid), retrait du bandeau « API absente ou incomplete » de l'accueil, et véritables pages d'erreur « Livre introuvable » sur fiche et lecteur (suppression de getApiFallback, retour à l'accueil + réessayer — évolution #46) 2026-08-28 23:29:03 +02:00
644a4b0183 merge: feature/45-home-redesign dans develop (refonte sobre de l'accueil : cartes simplifiées et livres en cours limités à 3 visibles — évolution #45) 2026-08-28 23:07:43 +02:00
11bb529322 feat(web): accueil — refonte sobre et moderne : hero resserré, cartes de livres simplifiées (renderHomeBook : couverture + titre + auteur, sans pill de métadonnées ni Meter) et liste des livres en cours limitée à 3 éléments visibles avant scroll (tuiles 112px — évolution #45) 2026-08-28 23:07:39 +02:00
cf8779781f docs(readme): documenter la remote Gitea et le modèle de branches
Ajout de l'URL de clone (gitea.anthonybouteiller.ovh/blomios/ReadaBook) et
d'une note sur le git-flow simplifié main/develop/feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 22:47:37 +02:00
452a4b6455 merge: feature/44-home-progress-scroll dans develop (liste des livres en cours scrollable après 6 éléments — évolution #44) 2026-08-28 22:41:04 +02:00
1d0b389c1e feat(web): accueil — rendre scrollable la liste des livres en cours après 6 éléments visibles (hauteur de .continue-grid bornée à 6 tuiles de 118px, overflow-y auto, scrollbar-gutter stable, tuiles à block-size fixe — évolution #44) 2026-08-28 22:40:59 +02:00
a9996cf15f merge: fix/43-reader-mobile-pinch-jump dans develop (stabilisation du pinch-zoom mobile : ancrage du point médian, gel du suivi vertical, blocage du zoom natif — correctif #43) 2026-08-28 19:08:39 +02:00
c4fe4d9de6 fix(web): lecteur mobile — stabiliser le pinch-zoom (ancrage du point médian sous les doigts avec restauration du scroll par page, gel du commit vertical via data-reader-pinch-active pendant le pincement, preventDefault sur touchstart et touch-action: pan-x pan-y pour bloquer le zoom natif — correctif bug #43) 2026-08-28 19:08:34 +02:00
01723c6eb7 merge: fix/42-reader-mobile-slow-load dans develop (préchargeur CBZ vertical priorisé, concurrency 3 + fenêtre profonde, fin de la saturation réseau Firefox mobile — 4e correctif #42) 2026-08-27 18:06:55 +02:00
7b3b687035 fix(web): lecteur mobile — remplacer le préchargeur CBZ vertical exhaustif (24 requêtes concurrentes sur tout le livre) par un planificateur à priorités (concurrence 3, délai 100ms, pages adjacentes + fenêtre profonde à +90, annulation par génération) et passer la page courante en fetchPriority high pour un chargement rapide des pages sur Firefox mobile (4e correctif bug #42) 2026-08-27 18:06:50 +02:00
9a5e78af72 merge: fix/42-reader-vertical-deep-pages dans develop (préchargeur CBZ vertical concurrent pour les pages profondes — 3e correctif #42) 2026-08-26 17:39:31 +02:00
03c8657df9 fix(web): lecteur mobile — précharger les pages CBZ verticales en profondeur via un préchargeur concurrent (batch RAF des tailles d'images, tracking resynchronisé au scroll) pour éliminer les black screens sur les pages lointaines (3e correctif bug #42) 2026-08-26 17:39:28 +02:00
5f63faa648 merge: fix/42-reader-vertical-black-screen-persistent dans develop (fallback page-sized étendu au PDF vertical mobile — 2e correctif #42) 2026-08-26 17:14:18 +02:00
ab77d129d3 fix(web): lecteur mobile — étendre le fallback page-sized aux frames PDF verticales non rendues (même cellule grid que le canvas, fond papier stable) pour éliminer les black screens d'environ 1s au scroll vertical PDF (2e correctif bug #42) 2026-08-26 17:14:11 +02:00
13 changed files with 704 additions and 96 deletions

View File

@ -2,6 +2,16 @@
ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale. ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale.
## Dépôt distant
Le dépôt est hébergé sur un Gitea self-hosted et peut être cloné via :
```bash
git clone https://gitea.anthonybouteiller.ovh/blomios/ReadaBook.git
```
Le déploiement git suit un git-flow simplifié : `main` (releases), `develop` (intégration), `feature/*` / `fix/*` (travail en cours).
## Fonctionnalités MVP présentes ## Fonctionnalités MVP présentes
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`. - Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("book missing error pages", () => {
it("does not render fallback books on the book detail page", () => {
const source = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
it("does not open the reader with a fallback book", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("reader-missing-page");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
});

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react"; import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared"; import type { BookDto, ProgressDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client"; import { api } from "../api/client";
import { cleanBookDescription } from "../book/description"; import { cleanBookDescription } from "../book/description";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata"; import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui"; import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
@ -17,14 +17,17 @@ export function BookPage({ bookId }: { bookId: number }) {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
try { try {
const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]); const nextBook = await api.book(bookId);
setBook(nextBook); setBook(nextBook);
setProgress(nextProgress); try {
} catch (loadError) { setProgress(await api.progress(bookId));
const fallback = getApiFallback<BookDto>(loadError); } catch {
setBook(fallback ?? null);
setProgress(null); setProgress(null);
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible."); }
} catch {
setBook(null);
setProgress(null);
setError("Ce livre n'existe pas dans le catalogue.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -43,13 +46,20 @@ export function BookPage({ bookId }: { bookId: number }) {
if (loading && !book) return <LoadingState />; if (loading && !book) return <LoadingState />;
if (!book) { if (!book) {
return ( return (
<section className="book-error-page" role="alert">
<Panel> <Panel>
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} /> <EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="book-card-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}> <button className="ghost-button" onClick={() => void loadBook()}>
<RotateCcw size={17} /> <RotateCcw size={17} />
Reessayer Reessayer
</button> </button>
</div>
</Panel> </Panel>
</section>
); );
} }

View File

@ -1,22 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, ScanLine } from "lucide-react"; import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client"; import { api } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types"; import type { DashboardData } from "../api/types";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata"; import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui"; import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
export function HomePage() { export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null); const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()]) Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => { .then(([books, continueReading, libraries, jobs]) => {
if (!alive) return; if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs }); setState({ books, continueReading, libraries, jobs });
}) })
.catch(() => { .catch(() => {
@ -28,15 +25,33 @@ export function HomePage() {
}, []); }, []);
if (!state) return <LoadingState />; if (!state) return <LoadingState />;
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
const renderHomeBook = (
item: DashboardData["books"][number],
className = "home-book-card",
options: { href?: string; progressPercent?: number } = {}
) => {
const volumeLabel = bookVolumeLabel(item);
return (
<button key={item.id} className={className} onClick={() => navigate(options.href ?? `/book/${item.id}`)}>
<span className="home-book-cover" aria-hidden="true">
{item.coverPath ? <img src={api.bookCoverUrl(item.id)} alt="" /> : <BookOpen size={24} />}
</span>
<span className="home-book-copy">
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
<strong>{bookDisplayTitle(item)}</strong>
<span>{item.author ?? "Auteur inconnu"}</span>
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
</span>
</button>
);
};
return ( return (
<div className="page-grid"> <div className="page-grid home-page">
<section className="hero-band"> <section className="hero-band home-hero">
<div> <div>
<p>Cabinet de curiosites numerique</p> <h1>Reprendre la lecture.</h1>
<h1>Ouvrir, classer, reprendre.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
</div> </div>
<button className="primary-button" onClick={() => navigate("/search")}> <button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} /> <ScanLine size={18} />
@ -46,30 +61,17 @@ export function HomePage() {
<Panel className="span-2"> <Panel className="span-2">
<div className="section-heading"> <div className="section-heading">
<h2>Reprise de lecture</h2> <h2>Livres en cours</h2>
<span>{state.continueReading.length} traces</span> <span>{state.continueReading.length} lectures</span>
</div> </div>
{state.continueReading.length ? ( {state.continueReading.length ? (
<div className="continue-grid"> <div className="continue-grid">
{state.continueReading.map((item) => { {state.continueReading.map((item) =>
const metadataState = bookMetadataState(item.book); renderHomeBook(item.book, "home-book-card continue-tile", {
const publishedDate = displayPublishedDate(item.book.publishedDate); href: `/reader/${item.book.id}`,
const volumeLabel = bookVolumeLabel(item.book); progressPercent: item.progress.percent
return ( })
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}> )}
<span className="continue-cover" aria-hidden="true">
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
</span>
<span className="continue-copy">
<strong>{bookDisplayTitle(item.book)}</strong>
<span>{item.book.author ?? "Auteur inconnu"}</span>
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
<Meter value={item.progress.percent} />
</span>
</button>
);
})}
</div> </div>
) : ( ) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." /> <EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
@ -91,10 +93,8 @@ export function HomePage() {
</div> </div>
</Panel> </Panel>
<section className="book-grid span-3"> <section className="home-book-grid span-3">
{state.books.map((book) => ( {state.books.map((book) => renderHomeBook(book))}
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))}
</section> </section>
</div> </div>
); );

View File

@ -0,0 +1,62 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("home progress list layout", () => {
it("keeps the continue reading list scrollable after three visible books", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? "";
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(styles).toContain("--continue-tile-block-size: 112px");
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(styles).toContain("overflow-y: auto");
expect(styles).toContain("scrollbar-gutter: stable");
expect(continueTile).toContain("block-size: var(--continue-tile-block-size)");
expect(continueTile).toContain("overflow: hidden");
});
it("keeps home book cards scoped and simplified", () => {
const source = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
expect(source).toContain("renderHomeBook");
expect(source).toContain("home-book-card");
expect(source).toContain("progressPercent");
expect(source).toContain("<Meter value={options.progressPercent} />");
expect(source).toContain("progressPercent: item.progress.percent");
expect(source).not.toContain("<BookCard");
expect(source).not.toContain("FormatPill");
expect(source).not.toContain("bookMetadataState");
expect(source).not.toContain("bookMetadataStateLabel");
expect(source).not.toContain("displayPublishedDate");
expect(source).not.toContain("book.description");
expect(source).not.toContain("book.language");
expect(source).not.toContain("API absente ou incomplete");
expect(source).not.toContain("specimens de demonstration actifs");
expect(source).not.toContain("Catalogue branche sur le serveur local");
expect(source).not.toContain("Bibliotheque personnelle");
});
it("keeps visible book grids capped to five columns", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const bookGrid = styles.match(/\.book-grid\s*\{[^}]+\}/)?.[0] ?? "";
const homeBookGrid = styles.match(/\.home-book-grid\s*\{[^}]+\}/)?.[0] ?? "";
expect(bookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
expect(homeBookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
expect(bookGrid).toContain("width: 100%");
expect(homeBookGrid).toContain("width: 100%");
expect(bookGrid).not.toContain("max-width");
expect(homeBookGrid).not.toContain("max-width");
});
it("keeps the library list capped like continue reading", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const libraryButton = styles.match(/\.library-list button\s*\{[^}]+\}/)?.[0] ?? "";
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(styles).toContain("height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(libraryButton).toContain("block-size: var(--continue-tile-block-size)");
expect(libraryButton).toContain("overflow: hidden");
});
});

View File

@ -42,14 +42,24 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
return ( return (
<div className="page-grid"> <div className="page-grid">
<Panel className="profile-panel"> <Panel className="profile-panel">
<div className="profile-identity">
<span className="profile-avatar" aria-hidden="true">
<UserRound size={28} /> <UserRound size={28} />
</span>
<h1>{session.user?.name ?? "Lecteur invite"}</h1> <h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p> <p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span> <span>{session.user?.role ?? "vitrine"}</span>
</div>
<div className="profile-actions">
<button className="ghost-button" onClick={() => document.getElementById("profile-security-form")?.scrollIntoView({ block: "start" })}>
<KeyRound size={17} />
Modifier
</button>
<button className="ghost-button" onClick={logout}> <button className="ghost-button" onClick={logout}>
<LogOut size={17} /> <LogOut size={17} />
Sortir Sortir
</button> </button>
</div>
</Panel> </Panel>
<Panel className="span-2"> <Panel className="span-2">
<div className="section-heading"> <div className="section-heading">
@ -59,7 +69,7 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p> <p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
<ErrorRibbon message={error} /> <ErrorRibbon message={error} />
{success && <div className="success-ribbon">{success}</div>} {success && <div className="success-ribbon">{success}</div>}
<form className="stack-form" onSubmit={updateSecurity}> <form id="profile-security-form" className="stack-form" onSubmit={updateSecurity}>
<label> <label>
Email Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required /> <input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />

View File

@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("profile page polish", () => {
it("focuses the left column on identity and immediate actions", () => {
const source = readFileSync(new URL("./ProfilePage.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(source).toContain("profile-identity");
expect(source).toContain("profile-avatar");
expect(source).toContain("profile-actions");
expect(source).toContain('id="profile-security-form"');
expect(source).toContain("scrollIntoView");
expect(styles).toContain(".profile-identity");
expect(styles).toContain(".profile-actions");
});
});

View File

@ -1,16 +1,17 @@
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react"; import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared"; import type { BookDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client"; import { api } from "../api/client";
import { CbzReader } from "../reader/CbzReader"; import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader"; import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader"; import { PdfReader } from "../reader/PdfReader";
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomControls } from "../reader/ReaderShell"; import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell";
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout"; import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll"; import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress"; import { useReaderProgress } from "../reader/useReaderProgress";
import { navigate } from "../router"; import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
const idleControls: ReaderControls = { const idleControls: ReaderControls = {
canPrevious: false, canPrevious: false,
@ -74,10 +75,9 @@ export function ReaderPage({ bookId }: { bookId: number }) {
setError(undefined); setError(undefined);
try { try {
setBook(await api.book(bookId)); setBook(await api.book(bookId));
} catch (loadError) { } catch {
const fallback = getApiFallback<BookDto>(loadError); setBook(null);
setBook(fallback ?? null); setError("Ce livre n'existe pas dans le catalogue.");
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -159,7 +159,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
setMode("horizontal"); setMode("horizontal");
saveReaderMode("horizontal"); saveReaderMode("horizontal");
}, [currentVisiblePage, page, saveReaderMode]); }, [currentVisiblePage, page, saveReaderMode]);
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => { const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => {
const stage = document.querySelector(".reader-stage") as HTMLElement | null; const stage = document.querySelector(".reader-stage") as HTMLElement | null;
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5; const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0; const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0;
@ -169,6 +169,14 @@ export function ReaderPage({ bookId }: { bookId: number }) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!stage) return; if (!stage) return;
if (anchor) {
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
if (!target) return;
const rect = target.getBoundingClientRect();
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
return;
}
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2); stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY); stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
}); });
@ -179,7 +187,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
supportsZoom supportsZoom
? { ? {
zoom, zoom,
onZoomChange: (nextZoom) => changeZoom(nextZoom), onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP), onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP), onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT) onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
@ -198,6 +206,26 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[changeMode, mode, supportsMode] [changeMode, mode, supportsMode]
); );
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="reader-missing-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="reader-error-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
Réessayer
</button>
</div>
</Panel>
</section>
);
}
return ( return (
<ReaderShell <ReaderShell
title={book?.title ?? "Ouverture du lecteur"} title={book?.title ?? "Ouverture du lecteur"}
@ -243,14 +271,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</div> </div>
)} )}
> >
{!book ? ( {book.format === "pdf" ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
<button className="ghost-button" onClick={() => void loadBook()}>
Reessayer
</button>
</div>
) : book.format === "pdf" ? (
<PdfReader <PdfReader
url={fileUrl} url={fileUrl}
page={page} page={page}

View File

@ -6,11 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
import type { ReaderControls } from "./ReaderShell"; import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued"; type PageCommitStrategy = "immediate" | "queued";
type PreloadedComicImage = {
image: HTMLImageElement;
status: "loading" | "loaded" | "error";
};
type PreloadPriority = "adjacent" | "deep";
type VerticalPreloadScheduler = {
active: number;
generation: number;
timer: number | null;
queuedPages: Set<number>;
queues: Record<PreloadPriority, number[]>;
};
const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3;
const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100;
const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90;
const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4];
const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"];
function scheduleReaderFrame(callback: FrameRequestCallback) {
if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback);
callback(0);
return 0;
}
function cancelReaderFrame(frameId: number) {
if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId);
}
function createVerticalPreloadScheduler(): VerticalPreloadScheduler {
return {
active: 0,
generation: 0,
timer: null,
queuedPages: new Set<number>(),
queues: {
adjacent: [],
deep: []
}
};
}
function uniqueReaderPages(pages: Array<number | null>, currentPage: number, pageCount: number) {
const seen = new Set<number>();
return pages
.map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null))
.filter((candidate): candidate is number => {
if (!candidate || candidate === currentPage || seen.has(candidate)) return false;
seen.add(candidate);
return true;
});
}
function verticalImagePreloadPlan(currentPage: number, pageCount: number) {
const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount);
return {
adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount),
deep: uniqueReaderPages(
VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset),
currentPage,
pageCount
)
};
}
export function CbzReader({ export function CbzReader({
bookId, bookId,
@ -39,6 +102,10 @@ export function CbzReader({
const verticalInitialSyncDoneRef = useRef(false); const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0); const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0);
const preloadedImagesRef = useRef(new Map<number, PreloadedComicImage>());
const preloadSchedulerRef = useRef(createVerticalPreloadScheduler());
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
const imageSizeFlushFrameRef = useRef<number | null>(null);
const [pages, setPages] = useState<CbzPagesDto | null>(null); const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [documentError, setDocumentError] = useState<string>(); const [documentError, setDocumentError] = useState<string>();
const [pageError, setPageError] = useState<string>(); const [pageError, setPageError] = useState<string>();
@ -91,9 +158,95 @@ export function CbzReader({
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current); if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
verticalInitialSyncFrameRef.current = null; verticalInitialSyncFrameRef.current = null;
}, []); }, []);
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
pendingImageSizesRef.current[pageNumber] = size;
if (imageSizeFlushFrameRef.current !== null) return;
imageSizeFlushFrameRef.current = scheduleReaderFrame(() => {
imageSizeFlushFrameRef.current = null;
const pending = pendingImageSizesRef.current;
pendingImageSizesRef.current = {};
setImageSizes((current) => {
let changed = false;
const next = { ...current };
for (const [pageKey, nextSize] of Object.entries(pending)) {
const pageNumber = Number(pageKey);
const currentSize = current[pageNumber];
if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue;
next[pageNumber] = nextSize;
changed = true;
}
return changed ? next : current;
});
});
}, []);
const drainVerticalPreloadQueue = useCallback(() => {
const scheduler = preloadSchedulerRef.current;
scheduler.timer = null;
if (typeof Image === "undefined") return;
const nextPage = () => {
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
const pageNumber = scheduler.queues[priority].shift();
if (pageNumber) {
scheduler.queuedPages.delete(pageNumber);
return pageNumber;
}
}
return null;
};
while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) {
const pageNumber = nextPage();
if (!pageNumber) return;
if (preloadedImagesRef.current.has(pageNumber)) continue;
const generation = scheduler.generation;
const image = new Image();
preloadedImagesRef.current.set(pageNumber, { image, status: "loading" });
scheduler.active += 1;
image.decoding = "async";
image.loading = "eager";
image.onload = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" });
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight });
}
drainVerticalPreloadQueue();
};
image.onerror = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "error" });
drainVerticalPreloadQueue();
};
image.src = api.cbzPageUrl(bookId, pageNumber);
}
}, [bookId, queueImageSize]);
const enqueueVerticalPreload = useCallback(
(plan: Record<PreloadPriority, number[]>) => {
const scheduler = preloadSchedulerRef.current;
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
if (priority === "deep") {
for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber);
scheduler.queues.deep = [];
}
for (const pageNumber of plan[priority]) {
if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue;
scheduler.queuedPages.add(pageNumber);
scheduler.queues[priority].push(pageNumber);
}
}
if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return;
scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS);
},
[drainVerticalPreloadQueue]
);
const commitVisiblePage = useCallback( const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => { (stage: HTMLElement, allowInitialCommit = false) => {
if (!pages || !verticalTrackingReadyRef.current) return; if (!pages || !verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return; if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return; if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect(); const stageRect = stage.getBoundingClientRect();
@ -192,8 +345,19 @@ export function CbzReader({
useEffect(() => { useEffect(() => {
setImageSizes({}); setImageSizes({});
pendingImageSizesRef.current = {};
preloadedImagesRef.current.clear();
const scheduler = preloadSchedulerRef.current;
if (scheduler.timer !== null) window.clearTimeout(scheduler.timer);
preloadSchedulerRef.current = createVerticalPreloadScheduler();
preloadSchedulerRef.current.generation = scheduler.generation + 1;
}, [bookId, retryAttempt]); }, [bookId, retryAttempt]);
useEffect(() => {
if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
}, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
useEffect(() => { useEffect(() => {
const frame = frameRef.current; const frame = frameRef.current;
const stage = frame?.closest(".reader-stage") as HTMLElement | null; const stage = frame?.closest(".reader-stage") as HTMLElement | null;
@ -264,6 +428,7 @@ export function CbzReader({
if (!stage) return; if (!stage) return;
let frameId = 0; let frameId = 0;
const markUserScroll = () => { const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true; verticalUserScrollRef.current = true;
}; };
const markUserScrollKey = (event: KeyboardEvent) => { const markUserScrollKey = (event: KeyboardEvent) => {
@ -275,6 +440,8 @@ export function CbzReader({
commitVisiblePage(stage); commitVisiblePage(stage);
}; };
const onScroll = () => { const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
if (frameId) return; if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage); frameId = requestAnimationFrame(updateVisiblePage);
}; };
@ -297,6 +464,12 @@ export function CbzReader({
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
useEffect(
() => () => {
if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
},
[]
);
if (documentError) { if (documentError) {
return ( return (
@ -348,14 +521,13 @@ export function CbzReader({
<img <img
src={api.cbzPageUrl(bookId, item.page)} src={api.cbzPageUrl(bookId, item.page)}
alt={item.name} alt={item.name}
loading="lazy" fetchPriority={item.page === currentPage ? "high" : "auto"}
loading={item.page === currentPage ? "eager" : "lazy"}
style={verticalImageStyle(item.page)} style={verticalImageStyle(item.page)}
onLoad={(event) => { onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget; const { naturalWidth, naturalHeight } = event.currentTarget;
setImageSizes((current) => ({ preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
...current, queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
[item.page]: { width: naturalWidth, height: naturalHeight }
}));
}} }}
onError={() => setPageError(`Page ${item.page} indisponible.`)} onError={() => setPageError(`Page ${item.page} indisponible.`)}
/> />

View File

@ -190,6 +190,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
const commitVisiblePage = useCallback( const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => { (stage: HTMLElement, allowInitialCommit = false) => {
if (!verticalTrackingReadyRef.current) return; if (!verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return; if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return; if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect(); const stageRect = stage.getBoundingClientRect();
@ -330,6 +331,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
if (!stage) return; if (!stage) return;
let frameId = 0; let frameId = 0;
const markUserScroll = () => { const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true; verticalUserScrollRef.current = true;
}; };
const markUserScrollKey = (event: KeyboardEvent) => { const markUserScrollKey = (event: KeyboardEvent) => {
@ -341,6 +343,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
commitVisiblePage(stage); commitVisiblePage(stage);
}; };
const onScroll = () => { const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (frameId) return; if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage); frameId = requestAnimationFrame(updateVisiblePage);
}; };

View File

@ -25,7 +25,7 @@ export type ReaderControls = {
export type ReaderZoomControls = { export type ReaderZoomControls = {
zoom: number; zoom: number;
onZoomChange: (zoom: number) => void; onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void;
onZoomOut: () => void; onZoomOut: () => void;
onZoomIn: () => void; onZoomIn: () => void;
onZoomReset: () => void; onZoomReset: () => void;
@ -47,6 +47,64 @@ type ReaderShellProps = {
children: ReactNode; children: ReactNode;
}; };
export type ReaderZoomAnchor = {
page: number;
clientX: number;
clientY: number;
offsetX: number;
offsetY: number;
ratioX: number;
ratioY: number;
};
type ReaderPinchGesture = {
distance: number;
zoom: number;
anchor: ReaderZoomAnchor | null;
};
function readerTouchPoint(touch: Touch): ReaderPoint {
return { x: touch.clientX, y: touch.clientY };
}
function readerTouchMidpoint(first: Touch, second: Touch): ReaderPoint {
return {
x: (first.clientX + second.clientX) / 2,
y: (first.clientY + second.clientY) / 2
};
}
function readerZoomAnchorAt(point: ReaderPoint): ReaderZoomAnchor | null {
const pageElement = document.elementFromPoint(point.x, point.y)?.closest("[data-reader-page]");
if (!(pageElement instanceof HTMLElement)) return null;
const page = Number(pageElement.dataset.readerPage);
const rect = pageElement.getBoundingClientRect();
if (!Number.isFinite(page) || rect.width <= 0 || rect.height <= 0) return null;
return {
page,
clientX: point.x,
clientY: point.y,
offsetX: point.x - rect.left,
offsetY: point.y - rect.top,
ratioX: Math.max(0, Math.min(1, (point.x - rect.left) / rect.width)),
ratioY: Math.max(0, Math.min(1, (point.y - rect.top) / rect.height))
};
}
function restoreReaderZoomAnchor(stage: HTMLElement, anchor: ReaderZoomAnchor) {
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
if (!target) return;
const rect = target.getBoundingClientRect();
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
}
function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomAnchor) {
requestAnimationFrame(() => {
requestAnimationFrame(() => restoreReaderZoomAnchor(stage, anchor));
});
}
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) { export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
const readerRef = useRef<HTMLDivElement>(null); const readerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
@ -54,7 +112,7 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
const headerHideTimerRef = useRef<number | undefined>(undefined); const headerHideTimerRef = useRef<number | undefined>(undefined);
const tapStartRef = useRef<ReaderPoint | null>(null); const tapStartRef = useRef<ReaderPoint | null>(null);
const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>()); const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>());
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null); const pinchRef = useRef<ReaderPinchGesture | null>(null);
const [nativeFullscreen, setNativeFullscreen] = useState(false); const [nativeFullscreen, setNativeFullscreen] = useState(false);
const [fallbackFullscreen, setFallbackFullscreen] = useState(false); const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const [headerVisible, setHeaderVisible] = useState(true); const [headerVisible, setHeaderVisible] = useState(true);
@ -178,26 +236,35 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
const stage = stageRef.current; const stage = stageRef.current;
if (!stage || !zoomControls) return; if (!stage || !zoomControls) return;
const touchPoint = (touch: Touch): ReaderPoint => ({ x: touch.clientX, y: touch.clientY });
const startPinch = (event: TouchEvent) => { const startPinch = (event: TouchEvent) => {
if (!isMobileReaderViewport() || event.touches.length !== 2) { if (!isMobileReaderViewport() || event.touches.length !== 2) {
pinchRef.current = null; pinchRef.current = null;
stage.removeAttribute("data-reader-pinch-active");
return; return;
} }
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1])); event.preventDefault();
pinchRef.current = { distance, zoom: zoomControls.zoom }; const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
stage.dataset.readerPinchActive = "true";
tapStartRef.current = null;
pinchRef.current = { distance, zoom: zoomControls.zoom, anchor: readerZoomAnchorAt(midpoint) };
}; };
const movePinch = (event: TouchEvent) => { const movePinch = (event: TouchEvent) => {
if (!pinchRef.current || event.touches.length !== 2) return; if (!pinchRef.current || event.touches.length !== 2) return;
event.preventDefault(); event.preventDefault();
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1])); const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance)); const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
const anchor = pinchRef.current.anchor ? { ...pinchRef.current.anchor, clientX: midpoint.x, clientY: midpoint.y } : null;
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance), anchor ?? undefined);
if (anchor) scheduleReaderZoomAnchorRestore(stage, anchor);
}; };
const endPinch = (event: TouchEvent) => { const endPinch = (event: TouchEvent) => {
if (event.touches.length < 2) pinchRef.current = null; if (event.touches.length >= 2) return;
pinchRef.current = null;
stage.removeAttribute("data-reader-pinch-active");
}; };
stage.addEventListener("touchstart", startPinch, { passive: true }); stage.addEventListener("touchstart", startPinch, { passive: false });
stage.addEventListener("touchmove", movePinch, { passive: false }); stage.addEventListener("touchmove", movePinch, { passive: false });
stage.addEventListener("touchend", endPinch); stage.addEventListener("touchend", endPinch);
stage.addEventListener("touchcancel", endPinch); stage.addEventListener("touchcancel", endPinch);

View File

@ -222,11 +222,30 @@ describe("reader runtime helpers", () => {
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}'); expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
}); });
it("keeps unloaded CBZ vertical pages visually covered by a page-sized fallback", () => { it("keeps CBZ vertical image loading on the rendered page elements", () => {
const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90");
expect(source).toContain('VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]');
expect(source).toContain("scheduler.queues.deep = []");
expect(source).toContain("!verticalUserScrollRef.current");
expect(source).toContain("preloadSchedulerRef");
expect(source).toContain("verticalImagePreloadPlan");
expect(source).toContain("new Image()");
expect(source).toContain('fetchPriority={item.page === currentPage ? "high" : "auto"}');
expect(source).toContain('loading={item.page === currentPage ? "eager" : "lazy"}');
expect(source).toContain("queueImageSize");
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
});
it("keeps unloaded PDF and CBZ vertical pages visually covered by a page-sized fallback", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8"); const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(styles).toContain(".pdf-page-frame-vertical > canvas,\n.pdf-page-frame-vertical > .reader-fallback");
expect(styles).toContain(".comic-page-frame-vertical > img,\n.comic-page-frame-vertical > .reader-fallback"); expect(styles).toContain(".comic-page-frame-vertical > img,\n.comic-page-frame-vertical > .reader-fallback");
expect(styles).toContain(".comic-page-frame-vertical > .reader-fallback {\n width: 100%;"); expect(styles).toContain(".pdf-page-frame-vertical > .reader-fallback,\n.comic-page-frame-vertical > .reader-fallback {\n width: 100%;");
expect(styles).toContain("min-height: inherit"); expect(styles).toContain("min-height: inherit");
expect(styles).toContain("background: #f7f0df"); expect(styles).toContain("background: #f7f0df");
}); });
@ -259,6 +278,22 @@ describe("reader runtime helpers", () => {
expect(source).toContain("onPointerUp={handleReaderPointerUp}"); expect(source).toContain("onPointerUp={handleReaderPointerUp}");
expect(source).toContain("readerPinchZoom"); expect(source).toContain("readerPinchZoom");
expect(source).toContain("zoomControls.onZoomChange"); expect(source).toContain("zoomControls.onZoomChange");
expect(source).toContain("data-reader-pinch-active");
expect(source).toContain("scheduleReaderZoomAnchorRestore");
expect(source).toContain("event.preventDefault()");
});
it("keeps mobile pinch zoom isolated from native zoom and vertical page commits", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const shell = readFileSync(new URL("./ReaderShell.tsx", import.meta.url), "utf8");
const pdfReader = readFileSync(new URL("./PdfReader.tsx", import.meta.url), "utf8");
const cbzReader = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
expect(styles).toContain("touch-action: pan-x pan-y");
expect(shell).toContain('stage.dataset.readerPinchActive = "true"');
expect(shell).toContain('stage.removeAttribute("data-reader-pinch-active")');
expect(pdfReader).toContain('if (stage.dataset.readerPinchActive === "true") return;');
expect(cbzReader).toContain('if (stage.dataset.readerPinchActive === "true") return;');
}); });
it("detects fullscreen support and active fullscreen element", () => { it("detects fullscreen support and active fullscreen element", () => {

View File

@ -161,9 +161,12 @@ h2 {
} }
.book-grid { .book-grid {
--book-grid-column-min: 240px;
--book-grid-gap: 16px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr));
gap: 16px; gap: var(--book-grid-gap);
width: 100%;
} }
.book-card { .book-card {
@ -237,6 +240,15 @@ h2 {
white-space: pre-line; white-space: pre-line;
} }
.book-error-page,
.reader-missing-page {
display: grid;
align-items: center;
width: min(100%, 680px);
min-height: 60vh;
margin: 0 auto;
}
.book-card-description { .book-card-description {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
@ -356,6 +368,24 @@ h2 {
background: rgba(169, 72, 52, 0.12); background: rgba(169, 72, 52, 0.12);
} }
.home-page {
align-items: start;
gap: 18px;
}
.home-hero {
min-height: 176px;
border-color: rgba(247, 240, 223, 0.13);
background:
linear-gradient(180deg, rgba(247, 240, 223, 0.08), rgba(247, 240, 223, 0.025)),
rgba(23, 17, 13, 0.74);
}
.home-hero h1 {
max-width: 740px;
font-size: clamp(2.2rem, 5vw, 4.1rem);
}
.continue-grid, .continue-grid,
.library-list, .library-list,
.job-list { .job-list {
@ -363,6 +393,17 @@ h2 {
gap: 10px; gap: 10px;
} }
.continue-grid,
.library-list {
--continue-visible-rows: 3;
--continue-tile-block-size: 112px;
height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
overflow-y: auto;
padding-right: 4px;
scrollbar-gutter: stable;
}
.job-list { .job-list {
max-height: 350px; max-height: 350px;
overflow: auto; overflow: auto;
@ -407,8 +448,95 @@ h2 {
} }
.continue-tile { .continue-tile {
grid-template-columns: 52px minmax(0, 1fr); block-size: var(--continue-tile-block-size);
align-items: start; overflow: hidden;
}
.library-list button {
block-size: var(--continue-tile-block-size);
overflow: hidden;
}
.home-book-grid {
--book-grid-column-min: 190px;
--book-grid-gap: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr));
gap: var(--book-grid-gap);
width: 100%;
}
.home-book-card {
display: grid;
grid-template-columns: 62px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-width: 0;
padding: 10px;
border: 1px solid rgba(247, 240, 223, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
color: var(--ink);
text-align: left;
}
.home-book-card:hover {
border-color: rgba(213, 168, 77, 0.42);
background: rgba(255, 255, 255, 0.055);
}
.home-book-cover {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid rgba(247, 240, 223, 0.14);
border-radius: 5px;
background:
linear-gradient(135deg, rgba(213, 168, 77, 0.16), rgba(45, 111, 99, 0.16)),
var(--paper-soft);
color: var(--brass);
}
.home-book-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.home-book-copy {
display: grid;
gap: 5px;
min-width: 0;
}
.home-book-copy strong,
.home-book-copy span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-book-copy strong {
line-height: 1.15;
}
.home-book-copy > span {
color: var(--ink-muted);
}
.home-book-volume {
width: max-content;
max-width: 100%;
padding: 3px 6px;
border: 1px solid rgba(213, 168, 77, 0.42);
border-radius: 999px;
color: #f5dfaa;
font-size: 0.72rem;
font-weight: 900;
background: rgba(213, 168, 77, 0.1);
} }
.continue-cover { .continue-cover {
@ -857,8 +985,54 @@ select {
.profile-panel { .profile-panel {
display: grid; display: grid;
gap: 10px; align-content: start;
gap: 18px;
}
.profile-identity {
display: grid;
justify-items: start; justify-items: start;
gap: 8px;
}
.profile-avatar {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border: 1px solid rgba(247, 240, 223, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.045);
color: var(--brass);
}
.profile-identity h1 {
margin-bottom: 0;
font-size: 1.8rem;
line-height: 1.05;
}
.profile-identity p {
margin-bottom: 0;
color: var(--ink-muted);
overflow-wrap: anywhere;
}
.profile-identity span:not(.profile-avatar) {
width: max-content;
max-width: 100%;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-muted);
font-size: 0.76rem;
font-weight: 900;
}
.profile-actions {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
} }
.reader-page { .reader-page {
@ -1023,6 +1197,7 @@ main.app-main-reader .reader-page {
min-width: 0; min-width: 0;
padding: 0; padding: 0;
overflow: auto; overflow: auto;
touch-action: pan-x pan-y;
} }
.reader-content { .reader-content {
@ -1114,11 +1289,14 @@ main.app-main-reader .reader-page {
padding: 0 12px; padding: 0 12px;
} }
.pdf-page-frame-vertical > canvas,
.pdf-page-frame-vertical > .reader-fallback,
.comic-page-frame-vertical > img, .comic-page-frame-vertical > img,
.comic-page-frame-vertical > .reader-fallback { .comic-page-frame-vertical > .reader-fallback {
grid-area: 1 / 1; grid-area: 1 / 1;
} }
.pdf-page-frame-vertical > .reader-fallback,
.comic-page-frame-vertical > .reader-fallback { .comic-page-frame-vertical > .reader-fallback {
width: 100%; width: 100%;
min-height: inherit; min-height: inherit;