diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index d4ce3c6..311dc9e 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -5,8 +5,10 @@ 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, type ReaderZoomControls } from "../reader/ReaderShell"; +import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomControls } from "../reader/ReaderShell"; import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout"; +import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll"; +import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderProgress } from "../reader/useReaderProgress"; const idleControls: ReaderControls = { @@ -23,8 +25,10 @@ export function ReaderPage({ bookId }: { bookId: number }) { const [error, setError] = useState(); const [page, setPage] = useState(1); const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT); + const [mode, setMode] = useState("horizontal"); const [readerControls, setReaderControls] = useState(idleControls); const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId); + const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId); async function loadBook() { setLoading(true); @@ -47,8 +51,14 @@ export function ReaderPage({ bookId }: { bookId: number }) { useEffect(() => { setReaderControls(idleControls); setZoom(READER_ZOOM_DEFAULT); + setMode("horizontal"); }, [bookId]); + useEffect(() => { + if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode); + else setMode("horizontal"); + }, [book?.format, preferences.mode]); + useEffect(() => { const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator); if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); @@ -79,8 +89,31 @@ export function ReaderPage({ bookId }: { bookId: number }) { [book?.format, queueSave, save] ); - const readerError = error ?? progressError; + const readerError = error ?? progressError ?? preferencesError; const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr"; + const supportsMode = supportsZoom; + const currentVisiblePage = useCallback(() => { + const stage = document.querySelector(".reader-stage") as HTMLElement | null; + if (!stage) return null; + const stageRect = stage.getBoundingClientRect(); + const pages = Array.from(stage.querySelectorAll("[data-reader-page]")) + .map((element) => { + const rect = element.getBoundingClientRect(); + const pageNumber = Number(element.dataset.readerPage); + return Number.isFinite(pageNumber) ? { page: pageNumber, top: rect.top, bottom: rect.bottom } : null; + }) + .filter((item): item is { page: number; top: number; bottom: number } => Boolean(item)); + return majorityVisiblePage(pages, stageRect.top, stageRect.bottom); + }, []); + const changeMode = useCallback( + (nextMode: ReaderMode) => { + const anchorPage = currentVisiblePage() ?? page; + setPage(anchorPage); + setMode(nextMode); + saveReaderMode(nextMode); + }, + [currentVisiblePage, page, saveReaderMode] + ); 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; @@ -108,6 +141,16 @@ export function ReaderPage({ bookId }: { bookId: number }) { : undefined, [changeZoom, supportsZoom, zoom] ); + const modeControls = useMemo( + () => + supportsMode + ? { + mode, + onModeChange: changeMode + } + : undefined, + [changeMode, mode, supportsMode] + ); return ( void loadBook() : undefined} controls={readerControls} zoomControls={zoomControls} + modeControls={modeControls} > {!book ? (
@@ -131,6 +175,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { page={page} backHref={backHref} zoom={zoom} + mode={mode} onPageCommit={savePdfPage} onControlsChange={setReaderControls} /> @@ -139,6 +184,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { bookId={book.id} page={page} zoom={zoom} + mode={mode} onPageCommit={saveComicPage} onControlsChange={setReaderControls} /> diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index e173504..b20a999 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import type { CbzPagesDto } from "../api/types"; import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout"; +import { majorityVisiblePage, type ReaderMode } from "./readerScroll"; import type { ReaderControls } from "./ReaderShell"; type PageCommitStrategy = "immediate" | "queued"; @@ -10,16 +11,19 @@ export function CbzReader({ bookId, page, zoom, + mode, onPageCommit, onControlsChange }: { bookId: number; page: number; zoom: number; + mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }) { const frameRef = useRef(null); + const previousModeRef = useRef(mode); const [pages, setPages] = useState(null); const [documentError, setDocumentError] = useState(); const [pageError, setPageError] = useState(); @@ -27,6 +31,7 @@ export function CbzReader({ const [retryAttempt, setRetryAttempt] = useState(0); const [viewportSize, setViewportSize] = useState(null); const [imageSize, setImageSize] = useState(null); + const [imageSizes, setImageSizes] = useState>({}); useEffect(() => { let alive = true; @@ -54,15 +59,27 @@ export function CbzReader({ const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null; const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined; + const verticalImageStyle = useCallback( + (pageNumber: number) => { + const size = imageSizes[pageNumber]; + if (!viewportSize || !size) return undefined; + const fitted = zoomReaderSize(orientedContainPageSize(viewportSize, size), zoom); + return { width: `${fitted.width}px`, height: `${fitted.height}px` }; + }, + [imageSizes, viewportSize, zoom] + ); const go = useCallback( (nextPage: number) => { if (!pages) return; const target = clampReaderPage(nextPage, pages.pageCount); setPageError(undefined); + if (mode === "vertical") { + frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); + } onPageCommit(target, pages.pageCount, 1, "immediate"); }, - [onPageCommit, pages] + [mode, onPageCommit, pages] ); useEffect(() => { @@ -70,6 +87,10 @@ export function CbzReader({ setImageSize(null); }, [bookId, currentPage, retryAttempt]); + useEffect(() => { + setImageSizes({}); + }, [bookId, retryAttempt]); + useEffect(() => { const frame = frameRef.current; const stage = frame?.closest(".reader-stage") as HTMLElement | null; @@ -100,6 +121,49 @@ export function CbzReader({ }); }, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]); + useEffect(() => { + if (mode !== "vertical") { + previousModeRef.current = mode; + return; + } + if (previousModeRef.current !== "vertical") { + requestAnimationFrame(() => { + frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); + }); + } + previousModeRef.current = mode; + }, [currentPage, mode]); + + useEffect(() => { + if (mode !== "vertical" || !pages) return; + const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null; + if (!stage) return; + let frameId = 0; + const updateVisiblePage = () => { + frameId = 0; + const stageRect = stage.getBoundingClientRect(); + const visiblePage = majorityVisiblePage( + Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { + const rect = element.getBoundingClientRect(); + return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom }; + }), + stageRect.top, + stageRect.bottom + ); + if (visiblePage && visiblePage !== currentPage) onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued"); + }; + const onScroll = () => { + if (frameId) return; + frameId = requestAnimationFrame(updateVisiblePage); + }; + stage.addEventListener("scroll", onScroll, { passive: true }); + updateVisiblePage(); + return () => { + if (frameId) cancelAnimationFrame(frameId); + stage.removeEventListener("scroll", onScroll); + }; + }, [currentPage, mode, onPageCommit, pages]); + if (documentError) { return (
@@ -137,25 +201,53 @@ export function CbzReader({ } return ( -
-
- {!imageSize && ( -
- Page {currentPage} -
- )} - { - setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); - }} - onError={() => setPageError(`Page ${currentPage} indisponible.`)} - /> -
+
+ {mode === "vertical" ? ( +
+ {pages.pages.map((item) => ( +
+ {!imageSizes[item.page] && ( +
+ Page {item.page} +
+ )} + { + setImageSizes((current) => ({ + ...current, + [item.page]: { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight } + })); + }} + onError={() => setPageError(`Page ${item.page} indisponible.`)} + /> +
+ ))} +
+ ) : ( +
+ {!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 77e209b..ec62213 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -5,6 +5,7 @@ import { pdfDocumentOptions } from "./pdfDocumentOptions"; import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender"; import { configurePdfWorker } from "./pdfWorker"; import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout"; +import { isPageInRenderWindow, majorityVisiblePage, type ReaderMode } from "./readerScroll"; import type { ReaderControls } from "./ReaderShell"; configurePdfWorker(pdfjs); @@ -16,6 +17,7 @@ type PdfReaderProps = { page: number; backHref: string; zoom: number; + mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }; @@ -60,9 +62,94 @@ function afterNextPaint() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } -export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsChange }: PdfReaderProps) { +function PdfPageCanvas({ + documentProxy, + pageNumber, + viewportSize, + zoom, + active +}: { + documentProxy: pdfjs.PDFDocumentProxy; + pageNumber: number; + viewportSize: ReaderSize | null; + zoom: number; + active: boolean; +}) { + const canvasRef = useRef(null); + const [loading, setLoading] = useState(false); + const [rendered, setRendered] = useState(false); + const [pageStatus, setPageStatus] = useState("idle"); + const [pageError, setPageError] = useState(); + const showCanvas = active && pdfCanvasVisible(rendered, Boolean(pageError)); + + useEffect(() => { + if (!active || !viewportSize) return; + const readerViewport = viewportSize; + let cancelled = false; + let renderTask: pdfjs.RenderTask | undefined; + setLoading(true); + setRendered(false); + setPageStatus("loading"); + setPageError(undefined); + + async function renderPage() { + try { + const pdfPage = await documentProxy.getPage(pageNumber); + if (cancelled) return; + const canvas = canvasRef.current; + if (!canvas) return; + 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 viewport = pdfPage.getViewport({ scale: Math.max(0.25, fitScale * readerZoomFactor(zoom)) * pixelRatio }); + const cssWidth = Math.floor(viewport.width / pixelRatio); + const cssHeight = Math.floor(viewport.height / pixelRatio); + canvas.width = Math.max(1, Math.floor(viewport.width)); + canvas.height = Math.max(1, Math.floor(viewport.height)); + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${cssHeight}px`; + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("PDF canvas context unavailable"); + context.fillStyle = pdfSentinelBackground(); + context.fillRect(0, 0, canvas.width, canvas.height); + renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); + await renderTask.promise; + await afterNextPaint(); + if (cancelled) return; + setPageStatus(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height))); + setRendered(true); + setLoading(false); + } catch (error) { + if (cancelled || isPdfTransitionError(error)) return; + setPageStatus("render-failed"); + setLoading(false); + setPageError(`Page ${pageNumber} indisponible.`); + } + } + + void renderPage(); + return () => { + cancelled = true; + renderTask?.cancel(); + }; + }, [active, documentProxy, pageNumber, viewportSize, zoom]); + + return ( +
+ {(!active || !viewportSize || loading || !rendered || pageError) && ( +
+ {pageError ?? `Page ${pageNumber}`} +
+ )} + +
+ ); +} + +export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) { const frameRef = useRef(null); const canvasRef = useRef(null); + const previousModeRef = useRef(mode); const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); const [documentError, setDocumentError] = useState(); @@ -82,9 +169,12 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC (nextPage: number) => { const target = clampReaderPage(nextPage, pages); setPageError(undefined); + if (mode === "vertical") { + frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); + } onPageCommit(target, pages, 1, "immediate"); }, - [onPageCommit, pages] + [mode, onPageCommit, pages] ); useEffect(() => { @@ -97,6 +187,49 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC }); }, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]); + useEffect(() => { + if (mode !== "vertical") { + previousModeRef.current = mode; + return; + } + if (previousModeRef.current !== "vertical") { + requestAnimationFrame(() => { + frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); + }); + } + previousModeRef.current = mode; + }, [currentPage, mode]); + + useEffect(() => { + if (mode !== "vertical") return; + const stage = scrollContainerFor(frameRef.current); + if (!stage) return; + let frameId = 0; + const updateVisiblePage = () => { + frameId = 0; + const stageRect = stage.getBoundingClientRect(); + const visiblePage = majorityVisiblePage( + Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { + const rect = element.getBoundingClientRect(); + return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom }; + }), + stageRect.top, + stageRect.bottom + ); + if (visiblePage && visiblePage !== currentPage) onPageCommit(clampReaderPage(visiblePage, pages), pages, 1, "queued"); + }; + const onScroll = () => { + if (frameId) return; + frameId = requestAnimationFrame(updateVisiblePage); + }; + stage.addEventListener("scroll", onScroll, { passive: true }); + updateVisiblePage(); + return () => { + if (frameId) cancelAnimationFrame(frameId); + stage.removeEventListener("scroll", onScroll); + }; + }, [currentPage, mode, onPageCommit, pages]); + useEffect(() => { const frame = frameRef.current; const stage = scrollContainerFor(frame); @@ -181,6 +314,10 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC }, [documentAttempt, url]); useEffect(() => { + if (mode === "vertical") { + setPageError(undefined); + return; + } if (!documentProxy || !viewportSize) return; const loadedDocument = documentProxy; const readerViewport = viewportSize; @@ -289,7 +426,7 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC cancelled = true; renderTask?.cancel(); }; - }, [currentPage, documentProxy, pageAttempt, viewportSize, zoom]); + }, [currentPage, documentProxy, mode, pageAttempt, viewportSize, zoom]); if (documentError) { return ( @@ -307,11 +444,27 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC } return ( -
+
{loadingDocument || !documentProxy ? (
Ouverture PDF
+ ) : mode === "vertical" ? ( +
+ {Array.from({ length: pages }, (_, index) => { + const pageNumber = index + 1; + return ( + + ); + })} +
) : pageError ? (
diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index 375daf1..87935e0 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -7,11 +7,12 @@ import { exitElementFullscreen, isElementFullscreen, isReaderHeaderActive, - readerArrowAction, - shouldHandleReaderArrowKey, + readerNavigationAction, + shouldHandleReaderNavigationKey, supportsElementFullscreen } from "./readerFullscreen"; import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout"; +import type { ReaderMode } from "./readerScroll"; export type ReaderControls = { canPrevious: boolean; @@ -28,6 +29,11 @@ export type ReaderZoomControls = { onZoomReset: () => void; }; +export type ReaderModeControls = { + mode: ReaderMode; + onModeChange: (mode: ReaderMode) => void; +}; + type ReaderShellProps = { title: string; backHref: string; @@ -35,17 +41,20 @@ type ReaderShellProps = { onRetry?: () => void; controls: ReaderControls; zoomControls?: ReaderZoomControls; + modeControls?: ReaderModeControls; children: ReactNode; }; -export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, children }: ReaderShellProps) { +export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) { const readerRef = useRef(null); const headerRef = useRef(null); + const stageRef = useRef(null); const headerHideTimerRef = useRef(undefined); const [nativeFullscreen, setNativeFullscreen] = useState(false); const [fallbackFullscreen, setFallbackFullscreen] = useState(false); const [headerVisible, setHeaderVisible] = useState(true); const fullscreenActive = nativeFullscreen || fallbackFullscreen; + const readerMode = modeControls?.mode ?? "horizontal"; const clearHeaderHideTimer = useCallback(() => { if (headerHideTimerRef.current === undefined) return; @@ -129,9 +138,15 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon setFallbackFullscreen(false); return; } - if (!shouldHandleReaderArrowKey(event, readerRef.current)) return; + const mode = modeControls?.mode ?? "horizontal"; + if (!shouldHandleReaderNavigationKey(event, readerRef.current, mode)) return; revealHeader(); - const action = readerArrowAction(event); + const action = readerNavigationAction(event, mode); + if (mode === "vertical") { + event.preventDefault(); + stageRef.current?.scrollBy({ top: (action === "next" ? 1 : -1) * Math.max(120, stageRef.current.clientHeight * 0.82), behavior: "smooth" }); + return; + } if (action === "previous" && controls.canPrevious) { event.preventDefault(); controls.onPrevious(); @@ -143,12 +158,12 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [controls, fallbackFullscreen, revealHeader]); + }, [controls, fallbackFullscreen, modeControls?.mode, revealHeader]); return (
{title} {controls.positionLabel} + {modeControls && ( + + )}
)} -
+
{children}
diff --git a/apps/web/src/reader/readerFullscreen.ts b/apps/web/src/reader/readerFullscreen.ts index 3f4030b..3fe61ee 100644 --- a/apps/web/src/reader/readerFullscreen.ts +++ b/apps/web/src/reader/readerFullscreen.ts @@ -31,13 +31,22 @@ type ReaderArrowEvent = { metaKey?: boolean; }; -export function readerArrowAction(event: ReaderArrowEvent): "previous" | "next" | null { +export function readerNavigationAction(event: ReaderArrowEvent, mode: "horizontal" | "vertical"): "previous" | "next" | null { if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return null; + if (mode === "vertical") { + if (event.key === "ArrowUp") return "previous"; + if (event.key === "ArrowDown") return "next"; + return null; + } if (event.key === "ArrowLeft") return "previous"; if (event.key === "ArrowRight") return "next"; return null; } +export function readerArrowAction(event: ReaderArrowEvent): "previous" | "next" | null { + return readerNavigationAction(event, "horizontal"); +} + export function fullscreenElement(documentRef: Document = document) { return documentRef.fullscreenElement; } @@ -74,7 +83,11 @@ export function isReaderHeaderActive(header: HTMLElement | null, documentRef: Do } export function shouldHandleReaderArrowKey(event: KeyboardEvent, readerElement: HTMLElement | null) { - if (!readerArrowAction(event)) return false; + return shouldHandleReaderNavigationKey(event, readerElement, "horizontal"); +} + +export function shouldHandleReaderNavigationKey(event: KeyboardEvent, readerElement: HTMLElement | null, mode: "horizontal" | "vertical") { + if (!readerNavigationAction(event, mode)) return false; if (isReaderInteractiveTarget(event.target)) return false; if (typeof Node === "undefined" || !readerElement || !(event.target instanceof Node)) return true; const documentRef = readerElement.ownerDocument; diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index ca1e86c..7e85138 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -15,8 +15,9 @@ import { zoomReaderSize } from "./readerLayout"; import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender"; -import { isElementFullscreen, isReaderHeaderActive, isReaderInteractiveTarget, readerArrowAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; +import { isElementFullscreen, isReaderHeaderActive, isReaderInteractiveTarget, readerArrowAction, readerNavigationAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; +import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from "./readerScroll"; import { readerErrorMessage } from "./ReaderError"; describe("reader runtime helpers", () => { @@ -174,6 +175,35 @@ describe("reader runtime helpers", () => { expect(readerArrowAction({ key: "ArrowRight", ctrlKey: true })).toBeNull(); }); + it("maps vertical reader shortcuts to up and down only", () => { + expect(readerNavigationAction({ key: "ArrowUp" }, "vertical")).toBe("previous"); + expect(readerNavigationAction({ key: "ArrowDown" }, "vertical")).toBe("next"); + expect(readerNavigationAction({ key: "ArrowLeft" }, "vertical")).toBeNull(); + expect(readerNavigationAction({ key: "ArrowRight" }, "vertical")).toBeNull(); + }); + + it("keeps a bounded render window around the visible vertical page", () => { + expect(verticalRenderWindow(8, 20)).toEqual({ start: 6, end: 10 }); + expect(verticalRenderWindow(1, 20)).toEqual({ start: 1, end: 3 }); + expect(verticalRenderWindow(20, 20)).toEqual({ start: 18, end: 20 }); + expect(isPageInRenderWindow(9, 8, 20)).toBe(true); + expect(isPageInRenderWindow(12, 8, 20)).toBe(false); + }); + + it("identifies the page with the largest visible vertical area", () => { + expect( + majorityVisiblePage( + [ + { page: 1, top: -200, bottom: 250 }, + { page: 2, top: 260, bottom: 760 }, + { page: 3, top: 770, bottom: 1200 } + ], + 0, + 700 + ) + ).toBe(2); + }); + it("detects fullscreen support and active fullscreen element", () => { const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement; const regularElement = {} as HTMLElement; diff --git a/apps/web/src/reader/readerScroll.ts b/apps/web/src/reader/readerScroll.ts new file mode 100644 index 0000000..f32d3d3 --- /dev/null +++ b/apps/web/src/reader/readerScroll.ts @@ -0,0 +1,36 @@ +import { clampReaderPage } from "./readerLayout"; + +export type ReaderMode = "horizontal" | "vertical"; + +export type ReaderPageVisibility = { + page: number; + top: number; + bottom: number; +}; + +export function verticalRenderWindow(page: number, pageCount: number, radius = 2) { + const currentPage = clampReaderPage(page, pageCount); + return { + start: clampReaderPage(currentPage - radius, pageCount), + end: clampReaderPage(currentPage + radius, pageCount) + }; +} + +export function isPageInRenderWindow(page: number, currentPage: number, pageCount: number, radius = 2) { + const window = verticalRenderWindow(currentPage, pageCount, radius); + return page >= window.start && page <= window.end; +} + +export function majorityVisiblePage(pages: ReaderPageVisibility[], viewportTop: number, viewportBottom: number) { + let bestPage: number | null = null; + let bestVisibleHeight = 0; + for (const page of pages) { + const visibleHeight = Math.max(0, Math.min(page.bottom, viewportBottom) - Math.max(page.top, viewportTop)); + if (visibleHeight > bestVisibleHeight) { + bestVisibleHeight = visibleHeight; + bestPage = page.page; + } + } + return bestPage; +} + diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index fe3718c..833bf1b 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -962,6 +962,24 @@ main.app-main-reader .reader-page { white-space: nowrap; } +.reader-mode-control { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + color: var(--ink-muted); + font-size: 0.82rem; + white-space: nowrap; +} + +.reader-mode-control select { + min-height: 32px; + border: 1px solid var(--line); + border-radius: var(--radius); + color: var(--ink); + background: rgba(255, 255, 255, 0.06); +} + .reader-title { flex: 1 1 auto; overflow: hidden; @@ -1016,6 +1034,11 @@ main.app-main-reader .reader-page { height: max-content; } +.reader-mode-vertical .reader-content { + align-items: start; + width: 100%; +} + .pdf-reader, .epub-reader, .cbz-reader { @@ -1025,6 +1048,23 @@ main.app-main-reader .reader-page { min-height: 100%; } +.pdf-reader-vertical, +.cbz-reader-vertical { + align-items: start; + width: 100%; + height: auto; +} + +.reader-vertical-stack { + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + width: 100%; + min-height: 100%; + padding: 12px 0 24px; +} + .epub-host { display: grid; width: 100%; @@ -1058,6 +1098,13 @@ main.app-main-reader .reader-page { margin: 0; } +.pdf-page-frame-vertical, +.comic-page-frame-vertical { + width: 100%; + min-height: 72vh; + padding: 0 12px; +} + .pdf-page-frame canvas, .cbz-reader img { display: block;