fix(web): lecteur PDF — pages blanches après rendu et base minimale stable

Le canvas PDF restait vide après rendu (blank-detected): la détection d'encre
reposait sur un seuil de blanc trop sensible et un fond de canvas non maîtrisé.
Le rendu passe à un fond sentinel explicite avec classification
rendered-ok/blank-detected et un seuil de pixels visibles.

Le lecteur est par ailleurs ramené à une base minimale stable: layout plein
viewport (app-main-reader), suppression des modes de lecture et des préférences
par livre, helpers de pagination/viewport extraits dans readerLayout.ts. Shell,
lecteurs CBZ/EPUB et styles alignés sur cette base.

Refs #31

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-24 12:52:49 +02:00
parent 7cc44871d9
commit 50a0b392e1
10 changed files with 662 additions and 714 deletions

View File

@ -1,180 +1,79 @@
import { ArrowLeft, ArrowRight, ArrowUp, Columns2, Maximize2, Minimize2, RotateCcw, Rows3, Save } from "lucide-react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ErrorRibbon, Meter } from "../components/ui";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, type ReactNode } from "react";
import { ErrorRibbon } from "../components/ui";
import { navigate } from "../router";
import type { ReaderMode } from "../api/types";
export type ReaderControls = {
canPrevious: boolean;
canNext: boolean;
canTop?: boolean;
positionLabel: string;
onPrevious: () => void;
onNext: () => void;
onTop?: () => 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) {
const [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => {
const root = document.querySelector(".reader-page");
if (!document.fullscreenElement) void root?.requestFullscreen();
else void document.exitFullscreen();
}, []);
function isFormField(target: EventTarget | null) {
const element = target as HTMLElement | null;
const tagName = element?.tagName.toLowerCase();
return Boolean(element?.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select");
}
export function ReaderShell({ title, backHref, error, onRetry, controls, children }: ReaderShellProps) {
useEffect(() => {
const updateFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
updateFullscreen();
document.addEventListener("fullscreenchange", updateFullscreen);
return () => document.removeEventListener("fullscreenchange", updateFullscreen);
}, []);
const handleKeyDown = (event: KeyboardEvent) => {
if (isFormField(event.target)) return;
if (event.key === "ArrowLeft" && controls.canPrevious) {
event.preventDefault();
controls.onPrevious();
}
if (event.key === "ArrowRight" && controls.canNext) {
event.preventDefault();
controls.onNext();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [controls]);
return (
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
<div className="reader-page reader-mode-horizontal">
<header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(backHref)}>
<ArrowLeft size={17} />
Fiche
Retour
</button>
<div>
<strong>{title}</strong>
<span>{status}</span>
</div>
<div className="reader-toolbar">
<button
className={`ghost-button reader-mode-button ${mode === "horizontal" ? "active" : ""}`}
onClick={() => onModeChange("horizontal")}
aria-label="Lecture horizontale"
title="Lecture horizontale"
>
<Columns2 size={18} />
Horizontal
</button>
<button
className={`ghost-button reader-mode-button ${mode === "vertical" ? "active" : ""}`}
onClick={() => onModeChange("vertical")}
aria-label="Lecture verticale"
title="Lecture verticale"
>
<Rows3 size={18} />
Vertical
</button>
<button className="ghost-button icon-only" onClick={toggleFullscreen} aria-label={isFullscreen ? "Quitter le plein ecran" : "Plein ecran"}>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
{error && onRetry ? (
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
<RotateCcw size={18} />
<strong className="reader-title">{title}</strong>
<span className="reader-position">{controls.positionLabel}</span>
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
<ArrowLeft size={16} />
Precedent
</button>
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
Suivant
<ArrowRight size={16} />
</button>
</header>
{error && (
<div className="reader-status">
<ErrorRibbon message={error} />
{onRetry && (
<button className="ghost-button" onClick={onRetry}>
Reessayer
</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"
/>
</>
)}
)}
<main className="reader-stage">
<div className="reader-content">{children}</div>
</div>
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
{mode === "vertical" ? (
<button className="ghost-button" onClick={controls.onTop} disabled={!controls.canTop}>
<ArrowUp size={16} />
Haut
</button>
) : (
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
<ArrowLeft size={16} />
Precedent
</button>
)}
<span>{controls.positionLabel}</span>
{mode === "vertical" ? (
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
<ArrowLeft size={16} />
Page precedente
</button>
) : (
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
Suivant
<ArrowRight size={16} />
</button>
)}
</footer>
</main>
</div>
);
}