feat(web): zoom du lecteur — contrôles +/−/reset (50–300 %) sur PDF et CBZ

This commit is contained in:
Git Agent
2026-08-24 14:28:53 +02:00
parent b3bd678291
commit fcf1760031
7 changed files with 158 additions and 29 deletions

View File

@ -5,7 +5,8 @@ import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader";
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
import { ReaderShell, type ReaderControls, type ReaderZoomControls } from "../reader/ReaderShell";
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
import { useReaderProgress } from "../reader/useReaderProgress";
const idleControls: ReaderControls = {
@ -21,6 +22,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [page, setPage] = useState(1);
const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT);
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
@ -44,6 +46,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
useEffect(() => {
setReaderControls(idleControls);
setZoom(READER_ZOOM_DEFAULT);
}, [bookId]);
useEffect(() => {
@ -77,6 +80,34 @@ export function ReaderPage({ bookId }: { bookId: number }) {
);
const readerError = error ?? progressError;
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => {
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 scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? (stage.scrollTop + stage.clientHeight / 2) / stage.scrollHeight : 0.5;
setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom));
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!stage) return;
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
stage.scrollTop = Math.max(0, stage.scrollHeight * scrollRatioY - stage.clientHeight / 2);
});
});
}, []);
const zoomControls = useMemo<ReaderZoomControls | undefined>(
() =>
supportsZoom
? {
zoom,
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
}
: undefined,
[changeZoom, supportsZoom, zoom]
);
return (
<ReaderShell
@ -85,6 +116,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
error={readerError}
onRetry={error ? () => void loadBook() : undefined}
controls={readerControls}
zoomControls={zoomControls}
>
{!book ? (
<div className="reader-fallback">
@ -98,6 +130,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
url={fileUrl}
page={page}
backHref={backHref}
zoom={zoom}
onPageCommit={savePdfPage}
onControlsChange={setReaderControls}
/>
@ -105,6 +138,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
<CbzReader
bookId={book.id}
page={page}
zoom={zoom}
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { CbzPagesDto } from "../api/types";
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout";
import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued";
@ -9,11 +9,13 @@ type PageCommitStrategy = "immediate" | "queued";
export function CbzReader({
bookId,
page,
zoom,
onPageCommit,
onControlsChange
}: {
bookId: number;
page: number;
zoom: number;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
@ -50,7 +52,7 @@ export function CbzReader({
const pageCount = pages?.pageCount ?? 1;
const currentPage = clampReaderPage(page, pageCount);
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
const fittedSize = viewportSize && imageSize ? orientedContainPageSize(viewportSize, imageSize) : null;
const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null;
const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined;
const go = useCallback(

View File

@ -4,7 +4,7 @@ import { ReaderError } from "./ReaderError";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
import { configurePdfWorker } from "./pdfWorker";
import { clampReaderPage, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout";
import type { ReaderControls } from "./ReaderShell";
configurePdfWorker(pdfjs);
@ -15,6 +15,7 @@ type PdfReaderProps = {
url: string;
page: number;
backHref: string;
zoom: number;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void;
};
@ -59,7 +60,7 @@ function afterNextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange }: PdfReaderProps) {
export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsChange }: PdfReaderProps) {
const frameRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
@ -206,7 +207,8 @@ export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange
const baseViewport = pdfPage.getViewport({ scale: 1 });
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio;
const zoomedScale = fitScale * readerZoomFactor(zoom);
const renderScale = Math.max(0.25, zoomedScale) * pixelRatio;
const viewport = pdfPage.getViewport({ scale: renderScale });
const cssWidth = Math.floor(viewport.width / pixelRatio);
const cssHeight = Math.floor(viewport.height / pixelRatio);
@ -287,7 +289,7 @@ export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange
cancelled = true;
renderTask?.cancel();
};
}, [currentPage, documentProxy, pageAttempt, viewportSize]);
}, [currentPage, documentProxy, pageAttempt, viewportSize, zoom]);
if (documentError) {
return (

View File

@ -2,6 +2,7 @@ import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, type ReactNode } from "react";
import { ErrorRibbon } from "../components/ui";
import { navigate } from "../router";
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
export type ReaderControls = {
canPrevious: boolean;
@ -11,12 +12,20 @@ export type ReaderControls = {
onNext: () => void;
};
export type ReaderZoomControls = {
zoom: number;
onZoomOut: () => void;
onZoomIn: () => void;
onZoomReset: () => void;
};
type ReaderShellProps = {
title: string;
backHref: string;
error?: string;
onRetry?: () => void;
controls: ReaderControls;
zoomControls?: ReaderZoomControls;
children: ReactNode;
};
@ -26,7 +35,7 @@ function isFormField(target: EventTarget | null) {
return Boolean(element?.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select");
}
export function ReaderShell({ title, backHref, error, onRetry, controls, children }: ReaderShellProps) {
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, children }: ReaderShellProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isFormField(event.target)) return;
@ -60,6 +69,20 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, childre
Suivant
<ArrowRight size={16} />
</button>
{zoomControls && (
<div className="reader-zoom-controls" aria-label="Zoom du lecteur">
<button className="ghost-button reader-zoom-button" onClick={zoomControls.onZoomOut} disabled={zoomControls.zoom <= READER_ZOOM_MIN} aria-label="Reduire le zoom">
-
</button>
<span className="reader-zoom-label">{readerZoomLabel(zoomControls.zoom)}</span>
<button className="ghost-button reader-zoom-button" onClick={zoomControls.onZoomIn} disabled={zoomControls.zoom >= READER_ZOOM_MAX} aria-label="Augmenter le zoom">
+
</button>
<button className="ghost-button reader-zoom-reset" onClick={zoomControls.onZoomReset} disabled={zoomControls.zoom === READER_ZOOM_DEFAULT}>
Réinitialiser
</button>
</div>
)}
</header>
{error && (
<div className="reader-status">

View File

@ -3,6 +3,24 @@ export type ReaderSize = {
height: number;
};
export const READER_ZOOM_MIN = 50;
export const READER_ZOOM_MAX = 300;
export const READER_ZOOM_STEP = 25;
export const READER_ZOOM_DEFAULT = 100;
export function clampReaderZoom(zoom: number) {
const steppedZoom = Math.round(zoom / READER_ZOOM_STEP) * READER_ZOOM_STEP;
return Math.max(READER_ZOOM_MIN, Math.min(steppedZoom, READER_ZOOM_MAX));
}
export function readerZoomLabel(zoom: number) {
return `${clampReaderZoom(zoom)} %`;
}
export function readerZoomFactor(zoom: number) {
return clampReaderZoom(zoom) / 100;
}
export function clampReaderPage(page: number, pageCount: number) {
const safePageCount = Math.max(1, Math.floor(pageCount));
return Math.max(1, Math.min(Math.floor(page), safePageCount));
@ -45,6 +63,14 @@ export function orientedContainPageSize(viewport: ReaderSize, page: ReaderSize):
};
}
export function zoomReaderSize(size: ReaderSize, zoom: number): ReaderSize {
const factor = readerZoomFactor(zoom);
return {
width: Math.max(1, Math.floor(size.width * factor)),
height: Math.max(1, Math.floor(size.height * factor))
};
}
export function readableViewportSize(size: ReaderSize): ReaderSize | null {
if (size.width < 32 || size.height < 32) return null;
return {

View File

@ -1,7 +1,19 @@
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
import {
clampReaderPage,
clampReaderZoom,
containPageSize,
nextReaderPage,
orientedContainPageSize,
previousReaderPage,
readableViewportSize,
readerPositionLabel,
readerZoomFactor,
readerZoomLabel,
zoomReaderSize
} from "./readerLayout";
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
import { readerErrorMessage } from "./ReaderError";
@ -118,6 +130,20 @@ describe("reader runtime helpers", () => {
expect(orientedContainPageSize({ width: 500, height: 900 }, { width: 1200, height: 900 })).toEqual({ width: 500, height: 375 });
});
it("clamps reader zoom to 25 percent steps between 50 and 300", () => {
expect(clampReaderZoom(20)).toBe(50);
expect(clampReaderZoom(63)).toBe(75);
expect(clampReaderZoom(186)).toBe(175);
expect(clampReaderZoom(340)).toBe(300);
expect(readerZoomLabel(125)).toBe("125 %");
expect(readerZoomFactor(175)).toBe(1.75);
});
it("applies reader zoom above the contained media size", () => {
expect(zoomReaderSize({ width: 800, height: 600 }, 50)).toEqual({ width: 400, height: 300 });
expect(zoomReaderSize({ width: 800, height: 600 }, 200)).toEqual({ width: 1600, height: 1200 });
});
it("ignores unusable reader viewport measurements", () => {
expect(readableViewportSize({ width: 0, height: 833 })).toBeNull();
expect(readableViewportSize({ width: 1, height: 1 })).toBeNull();

View File

@ -890,6 +890,27 @@ main.app-main-reader .reader-page {
background: rgba(38, 26, 18, 0.84);
}
.reader-zoom-controls {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.reader-zoom-button {
width: 34px;
min-width: 34px;
padding: 0;
font-weight: 800;
}
.reader-zoom-label {
min-width: 58px;
color: var(--ink-muted);
text-align: center;
white-space: nowrap;
}
.reader-title {
flex: 1 1 auto;
overflow: hidden;
@ -928,17 +949,16 @@ main.app-main-reader .reader-page {
min-height: 0;
min-width: 0;
padding: 0;
overflow: hidden;
overflow: auto;
}
.reader-content {
display: grid;
place-items: center;
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
overflow: hidden;
min-width: 100%;
min-height: 100%;
width: max-content;
height: max-content;
}
.pdf-reader,
@ -946,10 +966,8 @@ main.app-main-reader .reader-page {
.cbz-reader {
display: grid;
place-items: center;
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
}
.epub-host {
@ -978,9 +996,10 @@ main.app-main-reader .reader-page {
.comic-page-frame {
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: 64px;
width: max-content;
height: max-content;
min-width: 100%;
min-height: max(64px, 100%);
margin: 0;
}
@ -989,8 +1008,6 @@ main.app-main-reader .reader-page {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: var(--radius);
@ -998,11 +1015,6 @@ main.app-main-reader .reader-page {
object-fit: contain;
}
.cbz-reader img {
max-width: 100%;
max-height: 100%;
}
.reader-fallback {
display: flex;
flex-wrap: wrap;
@ -1161,6 +1173,10 @@ main.app-main-reader .reader-page {
gap: 8px;
}
.reader-zoom-reset {
display: none;
}
.epub-host {
width: 100%;
}