diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 718d337..d4ce3c6 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -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(); const [page, setPage] = useState(1); + const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT); const [readerControls, setReaderControls] = useState(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( + () => + 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 ( void loadBook() : undefined} controls={readerControls} + zoomControls={zoomControls} > {!book ? (
@@ -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 }) { diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index e3a9870..e173504 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -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( diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index d062a8b..77e209b 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -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((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(null); const canvasRef = useRef(null); const [documentProxy, setDocumentProxy] = useState(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 ( diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index b53d269..e132dbd 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -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 + {zoomControls && ( +
+ + {readerZoomLabel(zoomControls.zoom)} + + +
+ )} {error && (
diff --git a/apps/web/src/reader/readerLayout.ts b/apps/web/src/reader/readerLayout.ts index 5a5ae1f..b02315f 100644 --- a/apps/web/src/reader/readerLayout.ts +++ b/apps/web/src/reader/readerLayout.ts @@ -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 { diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index dedbb19..b2c442a 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -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(); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index eee1a99..716eda6 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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%; }