diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a5e4683..b212d43 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -41,7 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi ); - return {content}; + return ( + + {content} + + ); } export function App() { diff --git a/apps/web/src/layout/AppShell.tsx b/apps/web/src/layout/AppShell.tsx index 4f695bf..0c54500 100644 --- a/apps/web/src/layout/AppShell.tsx +++ b/apps/web/src/layout/AppShell.tsx @@ -11,7 +11,7 @@ const navItems = [ { href: "/me", label: "Profil", icon: UserRound } ]; -export function AppShell({ children, session }: { children: ReactNode; session: Session }) { +export function AppShell({ children, session, readerLayout = false }: { children: ReactNode; session: Session; readerLayout?: boolean }) { return (
-
{children}
+
{children}
); } diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 64d7455..718d337 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -6,7 +6,6 @@ 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 { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderProgress } from "../reader/useReaderProgress"; const idleControls: ReaderControls = { @@ -23,9 +22,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { const [error, setError] = useState(); const [page, setPage] = useState(1); const [readerControls, setReaderControls] = useState(idleControls); - const [controlsVisible, setControlsVisible] = useState(true); - const { progress, saving, error: progressError, save, queueSave } = useReaderProgress(bookId); - const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId); + const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId); async function loadBook() { setLoading(true); @@ -47,7 +44,6 @@ export function ReaderPage({ bookId }: { bookId: number }) { useEffect(() => { setReaderControls(idleControls); - setControlsVisible(true); }, [bookId]); useEffect(() => { @@ -80,21 +76,15 @@ export function ReaderPage({ bookId }: { bookId: number }) { [book?.format, queueSave, save] ); - const readerError = error ?? progressError ?? preferencesError; + const readerError = error ?? progressError; return ( void loadBook() : undefined} - mode={preferences.mode} - onModeChange={setMode} controls={readerControls} - controlsVisible={controlsVisible} - onToggleControls={() => setControlsVisible((visible) => !visible)} > {!book ? (
@@ -108,7 +98,6 @@ export function ReaderPage({ bookId }: { bookId: number }) { url={fileUrl} page={page} backHref={backHref} - mode={preferences.mode} onPageCommit={savePdfPage} onControlsChange={setReaderControls} /> @@ -116,7 +105,6 @@ export function ReaderPage({ bookId }: { bookId: number }) { @@ -125,7 +113,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { url={fileUrl} locator={progress?.locator} backHref={backHref} - mode={preferences.mode} + mode="horizontal" onLocatorChange={saveEpubLocator} onControlsChange={setReaderControls} /> diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index c845b6c..e3a9870 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -1,39 +1,36 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; -import type { CbzPagesDto, ReaderMode } from "../api/types"; +import type { CbzPagesDto } from "../api/types"; +import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout"; import type { ReaderControls } from "./ReaderShell"; type PageCommitStrategy = "immediate" | "queued"; -function scrollContainerFor(element: HTMLElement | null): HTMLElement | null { - return element?.closest(".reader-stage") as HTMLElement | null; -} - export function CbzReader({ bookId, page, - mode, onPageCommit, onControlsChange }: { bookId: number; page: number; - mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }) { const frameRef = useRef(null); - const restoredRef = useRef(false); const [pages, setPages] = useState(null); - const [error, setError] = useState(); - const [imageError, setImageError] = useState(false); - const [visiblePage, setVisiblePage] = useState(page); + const [documentError, setDocumentError] = useState(); + const [pageError, setPageError] = useState(); + const [documentAttempt, setDocumentAttempt] = useState(0); + const [retryAttempt, setRetryAttempt] = useState(0); + const [viewportSize, setViewportSize] = useState(null); + const [imageSize, setImageSize] = useState(null); useEffect(() => { let alive = true; - setError(undefined); - setImageError(false); - restoredRef.current = false; + setDocumentError(undefined); + setPageError(undefined); + setPages(null); api .cbzPages(bookId) .then((nextPages) => { @@ -41,86 +38,95 @@ export function CbzReader({ setPages(nextPages); if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate"); }) - .catch(() => { - if (alive) setError("Archive CBZ indisponible."); + .catch((error) => { + if (!alive) return; + setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible."); }); return () => { alive = false; }; - }, [bookId]); + }, [bookId, documentAttempt]); const pageCount = pages?.pageCount ?? 1; - const currentPage = Math.max(1, Math.min(page, pageCount)); - const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pageCount)) : currentPage; + const currentPage = clampReaderPage(page, pageCount); const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; + const fittedSize = viewportSize && imageSize ? orientedContainPageSize(viewportSize, imageSize) : null; + const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined; const go = useCallback( (nextPage: number) => { - const target = Math.max(1, Math.min(nextPage, pageCount)); - setImageError(false); - if (mode === "vertical") { - frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); - setVisiblePage(target); - } - onPageCommit(target, pageCount, mode === "vertical" ? 0 : 1, "immediate"); + if (!pages) return; + const target = clampReaderPage(nextPage, pages.pageCount); + setPageError(undefined); + onPageCommit(target, pages.pageCount, 1, "immediate"); }, - [mode, onPageCommit, pageCount] + [onPageCommit, pages] ); - const goTop = useCallback(() => go(1), [go]); + useEffect(() => { + setPageError(undefined); + setImageSize(null); + }, [bookId, currentPage, retryAttempt]); + + useEffect(() => { + const frame = frameRef.current; + const stage = frame?.closest(".reader-stage") as HTMLElement | null; + const observedElement = stage ?? frame; + if (!observedElement) return; + const updateSize = () => { + const rect = observedElement.getBoundingClientRect(); + const nextSize = readableViewportSize({ width: rect.width, height: rect.height }); + if (nextSize) setViewportSize(nextSize); + }; + updateSize(); + const frameId = requestAnimationFrame(updateSize); + const observer = new ResizeObserver(updateSize); + observer.observe(observedElement); + return () => { + cancelAnimationFrame(frameId); + observer.disconnect(); + }; + }, []); useEffect(() => { onControlsChange({ - canPrevious: !error && !imageError && displayedPage > 1, - canNext: !error && !imageError && displayedPage < pageCount, - canTop: !error && !imageError && displayedPage > 1, - positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive", - onPrevious: () => go(displayedPage - 1), - onNext: () => go(displayedPage + 1), - onTop: goTop + canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1, + canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount, + positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement", + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) }); - }, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]); + }, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]); - useEffect(() => { - if (mode !== "vertical") return; - const root = scrollContainerFor(frameRef.current); - if (!root || !pages) return; - const handleScroll = () => { - const rootRect = root.getBoundingClientRect(); - const frames = Array.from(frameRef.current?.querySelectorAll("[data-reader-page]") ?? []); - const active = frames - .map((element) => { - const rect = element.getBoundingClientRect(); - const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top); - const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height))); - return { page: Number(element.dataset.readerPage), overlap, anchor }; - }) - .filter((item) => Number.isInteger(item.page) && item.overlap > 0) - .sort((left, right) => right.overlap - left.overlap)[0]; - if (!active) return; - setVisiblePage(active.page); - onPageCommit(active.page, pageCount, active.anchor, "queued"); - }; - root.addEventListener("scroll", handleScroll, { passive: true }); - handleScroll(); - return () => root.removeEventListener("scroll", handleScroll); - }, [mode, onPageCommit, pageCount, pages]); - - useEffect(() => { - if (mode !== "vertical" || !pages || restoredRef.current) return; - restoredRef.current = true; - requestAnimationFrame(() => { - frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); - setVisiblePage(currentPage); - }); - }, [currentPage, mode, pages]); - - if (error || imageError) { + if (documentError) { return ( -
+
+
+ {documentError} + +
+
+ ); + } + + if (!pages) { + return ( +
- {error ?? "Page CBZ indisponible."} -
+
+ ); + } + + if (pageError) { + return ( +
+
+ {pageError} +
@@ -129,26 +135,25 @@ export function CbzReader({ } return ( -
- {mode === "vertical" && pages ? ( -
- {pages.pages.map((item) => ( -
- {item.name setImageError(true)} - /> -
- ))} -
- ) : ( -
- {currentName setImageError(true)} /> -
- )} +
+
+ {!imageSize && ( +
+ Page {currentPage} +
+ )} + { + setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); + }} + onError={() => setPageError(`Page ${currentPage} indisponible.`)} + /> +
); } diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 06683a1..373f322 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjs from "pdfjs-dist"; import { ReaderError } from "./ReaderError"; -import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender"; +import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender"; import { configurePdfWorker } from "./pdfWorker"; +import { clampReaderPage, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout"; import type { ReaderControls } from "./ReaderShell"; -import type { ReaderMode } from "../api/types"; configurePdfWorker(pdfjs); @@ -14,77 +14,196 @@ type PdfReaderProps = { url: string; page: number; backHref: string; - mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }; -type PdfPageCanvasProps = { - document: pdfjs.PDFDocumentProxy; - pageNumber: number; - mode: ReaderMode; - frameSize: { width: number; height: number }; - onError: (message: string) => void; +type PdfFailure = { + detail: string; + technicalDetail: string; }; -let pdfLifecycle = Promise.resolve(); +type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed"; -function enqueuePdfLifecycle(work: () => Promise) { - const run = pdfLifecycle.then(work, work); - pdfLifecycle = run.then(() => undefined, () => undefined); - return run; +function pdfTechnicalMessage(error: unknown) { + if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`; + if (typeof error === "string" && error.trim()) return error; + return "Erreur inconnue."; +} + +function pdfFailure(source: string, detail: string, context: Record, error?: unknown): PdfFailure { + return { + detail, + technicalDetail: JSON.stringify( + { + source, + ...context, + error: error === undefined ? undefined : pdfTechnicalMessage(error) + }, + null, + 2 + ) + }; } function isPdfTransitionError(error: unknown) { return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled")); } -function pdfReaderErrorMessage(error: unknown) { - if (isPdfTransitionError(error)) return "Le lecteur PDF termine une ouverture precedente."; - return "Le PDF n'a pas pu s'ouvrir."; -} - function scrollContainerFor(element: HTMLElement | null): HTMLElement | null { return element?.closest(".reader-stage") as HTMLElement | null; } -function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) { +function afterNextPaint() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange }: PdfReaderProps) { + const frameRef = useRef(null); const canvasRef = useRef(null); - const wrapperRef = useRef(null); - const [visible, setVisible] = useState(mode === "horizontal"); - const [rendered, setRendered] = useState(false); + const [documentProxy, setDocumentProxy] = useState(null); + const [pages, setPages] = useState(1); + const [documentError, setDocumentError] = useState(); + const [pageError, setPageError] = useState(); + const [loadingDocument, setLoadingDocument] = useState(true); + const [loadingPage, setLoadingPage] = useState(false); + const [pageRendered, setPageRendered] = useState(false); + const [pageStatus, setPageStatus] = useState("idle"); + const [documentAttempt, setDocumentAttempt] = useState(0); + const [pageAttempt, setPageAttempt] = useState(0); + const [viewportSize, setViewportSize] = useState(null); + + const currentPage = clampReaderPage(page, pages); + const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError)); + + const go = useCallback( + (nextPage: number) => { + const target = clampReaderPage(nextPage, pages); + setPageError(undefined); + onPageCommit(target, pages, 1, "immediate"); + }, + [onPageCommit, pages] + ); useEffect(() => { - if (mode === "horizontal") { - setVisible(true); - return; + onControlsChange({ + canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1, + canNext: !loadingDocument && !documentError && !pageError && currentPage < pages, + positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages), + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) + }); + }, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]); + + useEffect(() => { + const frame = frameRef.current; + const stage = scrollContainerFor(frame); + const observedElement = stage ?? frame; + if (!observedElement) return; + const updateSize = () => { + const rect = observedElement.getBoundingClientRect(); + const nextSize = readableViewportSize({ width: rect.width, height: rect.height }); + if (nextSize) setViewportSize(nextSize); + }; + updateSize(); + const frameId = requestAnimationFrame(updateSize); + const observer = new ResizeObserver(updateSize); + observer.observe(observedElement); + return () => { + cancelAnimationFrame(frameId); + observer.disconnect(); + }; + }, []); + + useEffect(() => { + let cancelled = false; + let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined; + setLoadingDocument(true); + setDocumentError(undefined); + setPageError(undefined); + setPageRendered(false); + setPageStatus("idle"); + setDocumentProxy(null); + + async function loadDocument() { + try { + try { + new URL(url, window.location.href); + } catch (error) { + throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error); + } + configurePdfWorker(pdfjs); + loadingTask = pdfjs.getDocument({ url, withCredentials: true }); + const loadedDocument = await loadingTask.promise; + if (cancelled) { + await loadingTask.destroy(); + return; + } + setPages(loadedDocument.numPages); + setDocumentProxy(loadedDocument); + setLoadingDocument(false); + } catch (error) { + if (cancelled && isPdfTransitionError(error)) return; + if (!cancelled) { + setDocumentError( + typeof error === "object" && error && "detail" in error && "technicalDetail" in error + ? (error as PdfFailure) + : pdfFailure( + "getDocument", + "PDF: getDocument() a échoué.", + { + url, + resolvedUrl: (() => { + try { + return new URL(url, window.location.href).toString(); + } catch { + return null; + } + })(), + workerSrc: pdfjs.GlobalWorkerOptions.workerSrc, + hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort) + }, + error + ) + ); + setLoadingDocument(false); + } + } } - const wrapper = wrapperRef.current; - if (!wrapper) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries.some((entry) => entry.isIntersecting)) setVisible(true); - }, - { root: scrollContainerFor(wrapper), rootMargin: "900px 0px" } - ); - observer.observe(wrapper); - return () => observer.disconnect(); - }, [mode]); + + void loadDocument(); + return () => { + cancelled = true; + void loadingTask?.destroy(); + }; + }, [documentAttempt, url]); useEffect(() => { - if (!visible) return; + if (!documentProxy || !viewportSize) return; + const loadedDocument = documentProxy; + const readerViewport = viewportSize; let cancelled = false; let renderTask: pdfjs.RenderTask | undefined; - setRendered(false); + setLoadingPage(true); + setPageError(undefined); + setPageRendered(false); + setPageStatus("loading"); async function renderPage() { try { - const pdfPage = await document.getPage(pageNumber); + let pdfPage: pdfjs.PDFPageProxy; + try { + pdfPage = await loadedDocument.getPage(currentPage); + } catch (error) { + throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error); + } if (cancelled) return; const canvas = canvasRef.current; - if (!canvas) return; + if (!canvas) { + throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage }); + } const baseViewport = pdfPage.getViewport({ scale: 1 }); - const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height }); + 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 viewport = pdfPage.getViewport({ scale: renderScale }); @@ -95,189 +214,136 @@ function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPa canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; const context = canvas.getContext("2d", { alpha: false }); - if (!context) return; - context.fillStyle = "#f7f0df"; - context.fillRect(0, 0, canvas.width, canvas.height); - renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); - await renderTask.promise; - if (!pdfCanvasHasVisibleInk(context.getImageData(0, 0, canvas.width, canvas.height))) { - throw new Error(`La page PDF ${pageNumber} est blanche apres rendu.`); + if (!context) { + throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, { + pageNumber: currentPage, + canvasWidth: canvas.width, + canvasHeight: canvas.height + }); + } + if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) { + throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, { + pageNumber: currentPage, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + cssWidth, + cssHeight, + readerViewport + }); + } + context.fillStyle = pdfSentinelBackground(); + context.fillRect(0, 0, canvas.width, canvas.height); + try { + renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); + await renderTask.promise; + } catch (error) { + throw pdfFailure( + "render-failed", + `PDF: render() a échoué pour la page ${currentPage}.`, + { + result: "render-failed", + pageNumber: currentPage, + baseViewport: { width: baseViewport.width, height: baseViewport.height }, + renderViewport: { width: viewport.width, height: viewport.height }, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + cssWidth, + cssHeight, + readerViewport + }, + error + ); + } + await afterNextPaint(); + if (cancelled) return; + const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height)); + if (renderResult === "blank-detected") { + setPageStatus("blank-detected"); + setPageRendered(false); + setLoadingPage(false); + setPageError( + pdfFailure("blank-detected", `PDF: page ${currentPage} blanche détectée après rendu.`, { + result: "blank-detected", + pageNumber: currentPage, + baseViewport: { width: baseViewport.width, height: baseViewport.height }, + renderViewport: { width: viewport.width, height: viewport.height }, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + cssWidth, + cssHeight, + readerViewport + }) + ); + return; + } + if (!cancelled) { + setPageStatus("rendered-ok"); + setPageRendered(true); + setLoadingPage(false); } - if (!cancelled) setRendered(true); } catch (error) { - if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error)); + if (cancelled || isPdfTransitionError(error)) return; + setPageRendered(false); + setPageStatus("render-failed"); + setLoadingPage(false); + setPageError( + typeof error === "object" && error && "detail" in error && "technicalDetail" in error + ? (error as PdfFailure) + : pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error) + ); } } - void enqueuePdfLifecycle(renderPage); + void renderPage(); return () => { cancelled = true; renderTask?.cancel(); }; - }, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]); + }, [currentPage, documentProxy, pageAttempt, viewportSize]); - return ( -
- {!rendered && ( -
- Page {pageNumber} -
- )} - -
- ); -} - -export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) { - const frameRef = useRef(null); - const restoredRef = useRef(false); - const [documentProxy, setDocumentProxy] = useState(null); - const [pages, setPages] = useState(1); - const [visiblePage, setVisiblePage] = useState(page); - const [error, setError] = useState(); - const [loading, setLoading] = useState(true); - const [attempt, setAttempt] = useState(0); - const [frameSize, setFrameSize] = useState({ width: 980, height: 900 }); - - const currentPage = Math.max(1, Math.min(page, pages)); - const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pages)) : currentPage; - - const go = useCallback( - (nextPage: number) => { - const target = Math.max(1, Math.min(nextPage, pages)); - if (mode === "vertical") { - frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); - onPageCommit(target, pages, 0, "immediate"); - setVisiblePage(target); - } else { - onPageCommit(target, pages, 1, "immediate"); - } - }, - [mode, onPageCommit, pages] - ); - - const goTop = useCallback(() => go(1), [go]); - const handlePageError = useCallback((message: string) => { - setError(message); - }, []); - - useEffect(() => { - onControlsChange({ - canPrevious: !loading && !error && displayedPage > 1, - canNext: !loading && !error && displayedPage < pages, - canTop: !loading && !error && displayedPage > 1, - positionLabel: loading ? "Ouverture PDF" : `${displayedPage} / ${pages}`, - onPrevious: () => go(displayedPage - 1), - onNext: () => go(displayedPage + 1), - onTop: goTop - }); - }, [displayedPage, error, go, goTop, loading, onControlsChange, pages]); - - useEffect(() => { - const frame = frameRef.current; - if (!frame) return; - const updateSize = () => { - const rect = frame.getBoundingClientRect(); - setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) }); - }; - updateSize(); - const observer = new ResizeObserver(updateSize); - observer.observe(frame); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - let cancelled = false; - let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined; - setLoading(true); - setError(undefined); - setDocumentProxy(null); - restoredRef.current = false; - - async function loadDocument() { - try { - configurePdfWorker(pdfjs); - loadingTask = pdfjs.getDocument({ url, withCredentials: true }); - const loadedDocument = await loadingTask.promise; - if (cancelled) { - await loadingTask.destroy(); - return; - } - setPages(loadedDocument.numPages); - setDocumentProxy(loadedDocument); - setLoading(false); - } catch (loadError) { - if (cancelled && isPdfTransitionError(loadError)) return; - if (!cancelled) { - setError(pdfReaderErrorMessage(loadError)); - setLoading(false); - } - } - } - - void enqueuePdfLifecycle(loadDocument); - return () => { - cancelled = true; - void loadingTask?.destroy(); - }; - }, [attempt, url]); - - useEffect(() => { - if (mode !== "vertical") return; - const root = scrollContainerFor(frameRef.current); - if (!root || !documentProxy) return; - const handleScroll = () => { - const rootRect = root.getBoundingClientRect(); - const frames = Array.from(frameRef.current?.querySelectorAll("[data-reader-page]") ?? []); - const active = frames - .map((element) => { - const rect = element.getBoundingClientRect(); - const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top); - const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height))); - return { page: Number(element.dataset.readerPage), overlap, anchor }; - }) - .filter((item) => Number.isInteger(item.page) && item.overlap > 0) - .sort((left, right) => right.overlap - left.overlap)[0]; - if (!active) return; - setVisiblePage(active.page); - onPageCommit(active.page, pages, active.anchor, "queued"); - }; - root.addEventListener("scroll", handleScroll, { passive: true }); - handleScroll(); - return () => root.removeEventListener("scroll", handleScroll); - }, [documentProxy, mode, onPageCommit, pages]); - - useEffect(() => { - if (mode !== "vertical" || !documentProxy || restoredRef.current) return; - restoredRef.current = true; - requestAnimationFrame(() => { - frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); - setVisiblePage(currentPage); - }); - }, [currentPage, documentProxy, mode]); - - return ( -
- {error ? ( + if (documentError) { + return ( +
setAttempt((value) => value + 1)} + onRetry={() => setDocumentAttempt((value) => value + 1)} /> - ) : loading || !documentProxy ? ( +
+ ); + } + + return ( +
+ {loadingDocument || !documentProxy ? (
Ouverture PDF
- ) : mode === "vertical" ? ( -
- {Array.from({ length: pages }, (_, index) => ( - - ))} + ) : pageError ? ( +
+
+ {pageError.detail} + +
+ Détail technique +
{pageError.technicalDetail}
+
+
) : ( - +
+ {(!viewportSize || loadingPage || !pageRendered) && ( +
+ Page {currentPage} +
+ )} + +
)}
); diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index 0141280..b53d269 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -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 ( -
-
event.stopPropagation()}> +
+
-
- {title} - {status} -
-
- - - - {error && onRetry ? ( - + +
+ {error && ( +
+ + {onRetry && ( + - ) : ( - )}
-
-
event.stopPropagation()}> - - -
-
- {mode === "horizontal" && ( - <> - - -
-
event.stopPropagation()}> - {mode === "vertical" ? ( - - ) : ( - - )} - {controls.positionLabel} - {mode === "vertical" ? ( - - ) : ( - - )} -
+
); } diff --git a/apps/web/src/reader/pdfRender.ts b/apps/web/src/reader/pdfRender.ts index 0bead5a..c0193fb 100644 --- a/apps/web/src/reader/pdfRender.ts +++ b/apps/web/src/reader/pdfRender.ts @@ -1,34 +1,52 @@ -import type { ReaderMode } from "../api/types"; +import type { ReaderSize } from "./readerLayout"; -const PDF_RENDER_BACKGROUND = { red: 247, green: 240, blue: 223 }; -const PDF_BLANK_THRESHOLD = 245; -const PDF_BACKGROUND_TOLERANCE = 4; +const PDF_SENTINEL_BACKGROUND = { red: 247, green: 240, blue: 223 }; +const PDF_BACKGROUND_TOLERANCE = 5; +const PDF_VISIBLE_DELTA = 10; +const PDF_MIN_VISIBLE_PIXELS = 12; -export function pdfRenderScale(mode: ReaderMode, frameSize: { width: number; height: number }, pageSize: { width: number; height: number }) { - if (pageSize.width <= 0 || pageSize.height <= 0) return 1; - const fitWidth = frameSize.width / pageSize.width; - if (mode === "vertical") return Math.min(1.65, fitWidth); - return Math.min(fitWidth, frameSize.height / pageSize.height); +export type PdfRenderResult = "rendered-ok" | "blank-detected"; + +export function pdfRenderScale(frameSize: ReaderSize, pageSize: ReaderSize) { + if (frameSize.width <= 0 || frameSize.height <= 0 || pageSize.width <= 0 || pageSize.height <= 0) return 1; + return Math.min(frameSize.width / pageSize.width, frameSize.height / pageSize.height); } -function isNearRenderBackground(red: number, green: number, blue: number) { +export function pdfCanvasVisible(pageRendered: boolean, hasPageError: boolean) { + return pageRendered && !hasPageError; +} + +function isNearSentinel(red: number, green: number, blue: number) { return ( - Math.abs(red - PDF_RENDER_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE && - Math.abs(green - PDF_RENDER_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE && - Math.abs(blue - PDF_RENDER_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE + Math.abs(red - PDF_SENTINEL_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE && + Math.abs(green - PDF_SENTINEL_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE && + Math.abs(blue - PDF_SENTINEL_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE ); } -export function pdfCanvasHasVisibleInk(imageData: Pick) { +function isVisibleInk(red: number, green: number, blue: number, alpha: number) { + if (alpha < 24) return false; + if (isNearSentinel(red, green, blue)) return false; + const distanceFromWhite = Math.max(255 - red, 255 - green, 255 - blue); + return distanceFromWhite >= PDF_VISIBLE_DELTA; +} + +export function pdfCanvasHasVisibleContent(imageData: Pick) { + let visiblePixels = 0; const data = imageData.data; for (let index = 0; index < data.length; index += 4) { - const red = data[index] ?? 255; - const green = data[index + 1] ?? 255; - const blue = data[index + 2] ?? 255; - const alpha = data[index + 3] ?? 255; - if (alpha === 0) continue; - const nearWhite = red >= PDF_BLANK_THRESHOLD && green >= PDF_BLANK_THRESHOLD && blue >= PDF_BLANK_THRESHOLD; - if (!nearWhite && !isNearRenderBackground(red, green, blue)) return true; + if (isVisibleInk(data[index] ?? 255, data[index + 1] ?? 255, data[index + 2] ?? 255, data[index + 3] ?? 255)) { + visiblePixels += 1; + if (visiblePixels >= PDF_MIN_VISIBLE_PIXELS) return true; + } } return false; } + +export function classifyPdfCanvas(imageData: Pick): PdfRenderResult { + return pdfCanvasHasVisibleContent(imageData) ? "rendered-ok" : "blank-detected"; +} + +export function pdfSentinelBackground() { + return `rgb(${PDF_SENTINEL_BACKGROUND.red}, ${PDF_SENTINEL_BACKGROUND.green}, ${PDF_SENTINEL_BACKGROUND.blue})`; +} diff --git a/apps/web/src/reader/readerLayout.ts b/apps/web/src/reader/readerLayout.ts new file mode 100644 index 0000000..5a5ae1f --- /dev/null +++ b/apps/web/src/reader/readerLayout.ts @@ -0,0 +1,54 @@ +export type ReaderSize = { + width: number; + height: number; +}; + +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)); +} + +export function previousReaderPage(page: number, pageCount: number) { + return clampReaderPage(page - 1, pageCount); +} + +export function nextReaderPage(page: number, pageCount: number) { + return clampReaderPage(page + 1, pageCount); +} + +export function readerPositionLabel(page: number, pageCount: number) { + return `Page ${clampReaderPage(page, pageCount)} / ${Math.max(1, Math.floor(pageCount))}`; +} + +export function containPageSize(viewport: ReaderSize, page: ReaderSize): ReaderSize { + if (viewport.width <= 0 || viewport.height <= 0 || page.width <= 0 || page.height <= 0) { + return { width: 0, height: 0 }; + } + const scale = Math.min(viewport.width / page.width, viewport.height / page.height); + return { + width: Math.max(1, Math.floor(page.width * scale)), + height: Math.max(1, Math.floor(page.height * scale)) + }; +} + +export function orientedContainPageSize(viewport: ReaderSize, page: ReaderSize): ReaderSize { + if (viewport.width <= 0 || viewport.height <= 0 || page.width <= 0 || page.height <= 0) { + return { width: 0, height: 0 }; + } + const fitByHeight = page.height >= page.width; + const primaryScale = fitByHeight ? viewport.height / page.height : viewport.width / page.width; + const crossScale = fitByHeight ? viewport.width / page.width : viewport.height / page.height; + const scale = Math.min(primaryScale, crossScale); + return { + width: Math.max(1, Math.floor(page.width * scale)), + height: Math.max(1, Math.floor(page.height * scale)) + }; +} + +export function readableViewportSize(size: ReaderSize): ReaderSize | null { + if (size.width < 32 || size.height < 32) return null; + return { + width: Math.floor(size.width), + height: Math.floor(size.height) + }; +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index 5d1ecf7..467a858 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { epubFileName } from "./EpubReader"; -import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender"; +import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout"; +import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { readerErrorMessage } from "./ReaderError"; @@ -32,13 +33,80 @@ describe("reader runtime helpers", () => { expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible"); }); - it("separates PDF fit-page and fit-width scales", () => { - expect(pdfRenderScale("horizontal", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75); - expect(pdfRenderScale("vertical", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(1.65); + it("fits PDF pages by containment inside the reader viewport", () => { + expect(pdfRenderScale({ width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75); + expect(pdfRenderScale({ width: 800, height: 600 }, { width: 800, height: 400 })).toBe(1); }); - it("rejects blank PDF canvas renders", () => { - expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 247, 240, 223, 255]) })).toBe(false); - expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 32, 28, 24, 255]) })).toBe(true); + it("does not expose a PDF canvas before a successful page render", () => { + expect(pdfCanvasVisible(false, false)).toBe(false); + expect(pdfCanvasVisible(true, true)).toBe(false); + expect(pdfCanvasVisible(true, false)).toBe(true); + }); + + it("classifies blank PDF canvas data separately from render failures", () => { + expect(classifyPdfCanvas({ data: new Uint8ClampedArray([255, 255, 255, 255, 255, 255, 255, 255]) })).toBe("blank-detected"); + expect(classifyPdfCanvas({ data: new Uint8ClampedArray([247, 240, 223, 255, 247, 240, 223, 255]) })).toBe("blank-detected"); + + const inkPixels = new Uint8ClampedArray(12 * 4); + for (let index = 0; index < inkPixels.length; index += 4) { + inkPixels[index] = 32; + inkPixels[index + 1] = 28; + inkPixels[index + 2] = 24; + inkPixels[index + 3] = 255; + } + expect(classifyPdfCanvas({ data: inkPixels })).toBe("rendered-ok"); + }); + + it("does not treat isolated PDF canvas noise as visible content", () => { + const noisyPixels = new Uint8ClampedArray([ + 255, 255, 255, 255, + 32, 28, 24, 255, + 255, 255, 255, 255 + ]); + expect(pdfCanvasHasVisibleContent({ data: noisyPixels })).toBe(false); + }); + + it("classifies very light but non-empty PDF canvas data as rendered", () => { + const lightInkPixels = new Uint8ClampedArray(12 * 4); + for (let index = 0; index < lightInkPixels.length; index += 4) { + lightInkPixels[index] = 244; + lightInkPixels[index + 1] = 244; + lightInkPixels[index + 2] = 244; + lightInkPixels[index + 3] = 255; + } + + expect(classifyPdfCanvas({ data: lightInkPixels })).toBe("rendered-ok"); + }); + + it("contains page media inside the reader viewport without cropping", () => { + expect(containPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 }); + expect(containPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 }); + }); + + it("fits comic pages by intrinsic orientation without cropping", () => { + expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 }); + expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 }); + expect(orientedContainPageSize({ width: 500, height: 900 }, { width: 1200, height: 900 })).toEqual({ width: 500, height: 375 }); + }); + + it("ignores unusable reader viewport measurements", () => { + expect(readableViewportSize({ width: 0, height: 833 })).toBeNull(); + expect(readableViewportSize({ width: 1, height: 1 })).toBeNull(); + expect(readableViewportSize({ width: 1548.8, height: 833.2 })).toEqual({ width: 1548, height: 833 }); + }); + + it("keeps the minimal reader on one clamped page", () => { + expect(clampReaderPage(0, 12)).toBe(1); + expect(clampReaderPage(8, 12)).toBe(8); + expect(clampReaderPage(99, 12)).toBe(12); + expect(readerPositionLabel(8, 12)).toBe("Page 8 / 12"); + }); + + it("navigates page by page without leaving document bounds", () => { + expect(previousReaderPage(1, 12)).toBe(1); + expect(previousReaderPage(8, 12)).toBe(7); + expect(nextReaderPage(8, 12)).toBe(9); + expect(nextReaderPage(12, 12)).toBe(12); }); }); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index e16d657..eee1a99 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -68,6 +68,13 @@ main { padding: 28px; } +main.app-main-reader { + height: 100vh; + min-height: 0; + padding: 0; + overflow: hidden; +} + .page-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -856,87 +863,82 @@ select { .reader-page { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; - gap: 10px; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 8px; height: 100vh; min-height: 100vh; - padding: 14px; + padding: 10px; overflow: hidden; background: #120e0b; } -.reader-page:fullscreen { - width: 100vw; - height: 100vh; +main.app-main-reader .reader-page { + height: 100%; + min-height: 0; } .reader-topbar { - position: relative; - z-index: 5; - justify-content: space-between; - padding: 10px; + grid-row: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 42px; + padding: 6px 8px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(38, 26, 18, 0.84); } -.reader-topbar div { - display: grid; - justify-items: center; -} - -.reader-topbar span { - color: var(--ink-muted); - font-size: 0.82rem; -} - -.reader-toolbar { - display: flex; - align-items: center; - gap: 8px; -} - -.reader-toolbar .icon-only { - width: 40px; - padding: 0; -} - -.reader-toolbar .reader-mode-button { - gap: 7px; - padding-inline: 10px; +.reader-title { + flex: 1 1 auto; + overflow: hidden; + min-width: 0; + max-width: none; + text-overflow: ellipsis; white-space: nowrap; } -.reader-toolbar .active { - border-color: rgba(213, 168, 77, 0.72); - color: var(--brass); - background: rgba(213, 168, 77, 0.14); +.reader-position { + flex: 0 0 auto; + min-width: 92px; + color: var(--ink-muted); + text-align: center; +} + +.reader-topbar .ghost-button { + flex: 0 0 auto; + min-height: 32px; + white-space: nowrap; } .reader-status { - position: relative; - z-index: 4; + grid-row: 2; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; } .reader-stage { + grid-row: 3; position: relative; display: grid; - place-items: stretch; + place-items: center; min-height: 0; min-width: 0; padding: 0; overflow: hidden; } -.reader-mode-vertical .reader-stage { - overflow-y: auto; - overscroll-behavior: contain; -} - .reader-content { display: grid; + place-items: center; min-height: 0; min-width: 0; + width: 100%; + height: 100%; + overflow: hidden; } .pdf-reader, @@ -950,19 +952,19 @@ select { height: 100%; } -.pdf-reader-vertical, -.cbz-reader-vertical { - place-items: start center; - height: auto; -} - .epub-host { display: grid; - width: min(100%, 980px); + width: 100%; height: 100%; min-height: 0; } +.epub-reader { + place-items: stretch; + overflow: auto; + padding: 14px; +} + .epub-view { width: 100%; height: 100%; @@ -982,56 +984,23 @@ select { margin: 0; } -.pdf-page-frame canvas { +.pdf-page-frame canvas, +.cbz-reader img { display: block; - max-width: min(100%, 980px); + width: auto; + height: auto; + max-width: 100%; max-height: 100%; box-sizing: border-box; border: 1px solid var(--line); border-radius: var(--radius); background: #f7f0df; + object-fit: contain; } .cbz-reader img { - display: block; - box-sizing: border-box; - max-width: min(100%, 980px); + max-width: 100%; max-height: 100%; - border: 1px solid var(--line); - border-radius: var(--radius); - background: #f7f0df; - object-fit: contain; -} - -.reader-mode-vertical .pdf-reader, -.reader-mode-vertical .cbz-reader { - align-content: start; - padding-bottom: 20px; -} - -.pdf-strip, -.comic-strip { - display: grid; - justify-items: center; - gap: 18px; - width: 100%; - padding: 0 0 24px; -} - -.reader-mode-vertical .pdf-page-frame canvas, -.reader-mode-vertical .cbz-reader img { - width: min(100%, 980px); - height: auto; - max-height: none; - object-fit: contain; -} - -.reader-mode-horizontal .pdf-page-frame canvas, -.reader-mode-horizontal .cbz-reader img { - width: auto; - height: auto; - max-height: 100%; - object-fit: contain; } .reader-fallback { @@ -1044,71 +1013,6 @@ select { color: var(--ink-muted); } -.reader-stepper { - position: relative; - z-index: 5; - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - min-height: 46px; - padding: 6px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(38, 26, 18, 0.84); -} - -.reader-stepper span { - min-width: 92px; - color: var(--ink-muted); - text-align: center; -} - -.reader-side-button, -.reader-tap-zone { - position: absolute; - top: 0; - bottom: 0; - z-index: 3; - border: 0; - background: transparent; -} - -.reader-side-button { - display: grid; - place-items: center; - width: 58px; - color: var(--ink); - opacity: 0.66; -} - -.reader-side-button:hover { - opacity: 1; - background: rgba(255, 255, 255, 0.04); -} - -.reader-side-button:disabled, -.reader-tap-zone:disabled { - pointer-events: none; - opacity: 0; -} - -.reader-side-left, -.reader-tap-left { - left: 0; -} - -.reader-side-right, -.reader-tap-right { - right: 0; -} - -.reader-tap-zone { - display: none; - width: 34%; - opacity: 0; -} - .reader-error { display: grid; gap: 14px; @@ -1186,6 +1090,12 @@ select { padding: 16px 16px 86px; } + main.app-main-reader { + height: calc(100vh - 58px); + padding: 0 0 58px; + overflow: hidden; + } + .page-grid { grid-template-columns: 1fr; } @@ -1251,71 +1161,7 @@ select { gap: 8px; } - .reader-topbar div { - min-width: 0; - } - - .reader-topbar strong { - overflow: hidden; - max-width: 38vw; - text-overflow: ellipsis; - white-space: nowrap; - } - - .reader-toolbar { - gap: 5px; - } - - .reader-side-button { - display: none; - } - - .reader-tap-zone { - display: block; - } - - .reader-controls-hidden .reader-topbar, - .reader-controls-hidden .reader-status, - .reader-controls-hidden .reader-stepper { - opacity: 0; - pointer-events: none; - } - - .reader-controls-hidden { - grid-template-rows: 0 0 minmax(0, 1fr) 0; - gap: 0; - } - - .reader-controls-hidden .reader-topbar, - .reader-controls-hidden .reader-status, - .reader-controls-hidden .reader-stepper { - overflow: hidden; - min-height: 0; - padding-top: 0; - padding-bottom: 0; - } - - .reader-topbar, - .reader-status, - .reader-stepper { - transition: opacity 0.16s ease; - } - - .reader-stepper { - gap: 8px; - } - - .reader-stepper .ghost-button { - flex: 1 1 0; - min-width: 0; - } - .epub-host { width: 100%; } - - .reader-mode-vertical .pdf-reader canvas, - .reader-mode-vertical .cbz-reader img { - width: 100%; - } }