feat(web): zoom du lecteur — contrôles +/−/reset (50–300 %) sur PDF et CBZ
This commit is contained in:
@ -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}
|
||||
/>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user