feat(web): mode de lecture vertical type webtoon — pages empilées, défilement souris/flèches, préférence persistée
This commit is contained in:
@ -5,8 +5,10 @@ import { CbzReader } from "../reader/CbzReader";
|
|||||||
import { EpubReader } from "../reader/EpubReader";
|
import { EpubReader } from "../reader/EpubReader";
|
||||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||||
import { PdfReader } from "../reader/PdfReader";
|
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 { 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";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
|
||||||
const idleControls: ReaderControls = {
|
const idleControls: ReaderControls = {
|
||||||
@ -23,8 +25,10 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT);
|
const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT);
|
||||||
|
const [mode, setMode] = useState<ReaderMode>("horizontal");
|
||||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||||
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||||
|
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||||
|
|
||||||
async function loadBook() {
|
async function loadBook() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -47,8 +51,14 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReaderControls(idleControls);
|
setReaderControls(idleControls);
|
||||||
setZoom(READER_ZOOM_DEFAULT);
|
setZoom(READER_ZOOM_DEFAULT);
|
||||||
|
setMode("horizontal");
|
||||||
}, [bookId]);
|
}, [bookId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode);
|
||||||
|
else setMode("horizontal");
|
||||||
|
}, [book?.format, preferences.mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
||||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||||
@ -79,8 +89,31 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[book?.format, queueSave, save]
|
[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 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<HTMLElement>("[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 changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => {
|
||||||
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
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 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,
|
: undefined,
|
||||||
[changeZoom, supportsZoom, zoom]
|
[changeZoom, supportsZoom, zoom]
|
||||||
);
|
);
|
||||||
|
const modeControls = useMemo<ReaderModeControls | undefined>(
|
||||||
|
() =>
|
||||||
|
supportsMode
|
||||||
|
? {
|
||||||
|
mode,
|
||||||
|
onModeChange: changeMode
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[changeMode, mode, supportsMode]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderShell
|
<ReaderShell
|
||||||
@ -117,6 +160,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
onRetry={error ? () => void loadBook() : undefined}
|
onRetry={error ? () => void loadBook() : undefined}
|
||||||
controls={readerControls}
|
controls={readerControls}
|
||||||
zoomControls={zoomControls}
|
zoomControls={zoomControls}
|
||||||
|
modeControls={modeControls}
|
||||||
>
|
>
|
||||||
{!book ? (
|
{!book ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
@ -131,6 +175,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
page={page}
|
page={page}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
mode={mode}
|
||||||
onPageCommit={savePdfPage}
|
onPageCommit={savePdfPage}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
@ -139,6 +184,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
page={page}
|
page={page}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
mode={mode}
|
||||||
onPageCommit={saveComicPage}
|
onPageCommit={saveComicPage}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { CbzPagesDto } from "../api/types";
|
import type { CbzPagesDto } from "../api/types";
|
||||||
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout";
|
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout";
|
||||||
|
import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
|
||||||
type PageCommitStrategy = "immediate" | "queued";
|
type PageCommitStrategy = "immediate" | "queued";
|
||||||
@ -10,16 +11,19 @@ export function CbzReader({
|
|||||||
bookId,
|
bookId,
|
||||||
page,
|
page,
|
||||||
zoom,
|
zoom,
|
||||||
|
mode,
|
||||||
onPageCommit,
|
onPageCommit,
|
||||||
onControlsChange
|
onControlsChange
|
||||||
}: {
|
}: {
|
||||||
bookId: number;
|
bookId: number;
|
||||||
page: number;
|
page: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
mode: ReaderMode;
|
||||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const frameRef = useRef<HTMLDivElement>(null);
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previousModeRef = useRef<ReaderMode>(mode);
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
const [documentError, setDocumentError] = useState<string>();
|
const [documentError, setDocumentError] = useState<string>();
|
||||||
const [pageError, setPageError] = useState<string>();
|
const [pageError, setPageError] = useState<string>();
|
||||||
@ -27,6 +31,7 @@ export function CbzReader({
|
|||||||
const [retryAttempt, setRetryAttempt] = useState(0);
|
const [retryAttempt, setRetryAttempt] = useState(0);
|
||||||
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
||||||
const [imageSize, setImageSize] = useState<ReaderSize | null>(null);
|
const [imageSize, setImageSize] = useState<ReaderSize | null>(null);
|
||||||
|
const [imageSizes, setImageSizes] = useState<Record<number, ReaderSize>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@ -54,15 +59,27 @@ export function CbzReader({
|
|||||||
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
||||||
const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null;
|
const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null;
|
||||||
const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined;
|
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(
|
const go = useCallback(
|
||||||
(nextPage: number) => {
|
(nextPage: number) => {
|
||||||
if (!pages) return;
|
if (!pages) return;
|
||||||
const target = clampReaderPage(nextPage, pages.pageCount);
|
const target = clampReaderPage(nextPage, pages.pageCount);
|
||||||
setPageError(undefined);
|
setPageError(undefined);
|
||||||
|
if (mode === "vertical") {
|
||||||
|
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
onPageCommit(target, pages.pageCount, 1, "immediate");
|
onPageCommit(target, pages.pageCount, 1, "immediate");
|
||||||
},
|
},
|
||||||
[onPageCommit, pages]
|
[mode, onPageCommit, pages]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -70,6 +87,10 @@ export function CbzReader({
|
|||||||
setImageSize(null);
|
setImageSize(null);
|
||||||
}, [bookId, currentPage, retryAttempt]);
|
}, [bookId, currentPage, retryAttempt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageSizes({});
|
||||||
|
}, [bookId, retryAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = frameRef.current;
|
const frame = frameRef.current;
|
||||||
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
||||||
@ -100,6 +121,49 @@ export function CbzReader({
|
|||||||
});
|
});
|
||||||
}, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]);
|
}, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "vertical") {
|
||||||
|
previousModeRef.current = mode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previousModeRef.current !== "vertical") {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
frameRef.current?.querySelector<HTMLElement>(`[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<HTMLElement>("[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) {
|
if (documentError) {
|
||||||
return (
|
return (
|
||||||
<div className="cbz-reader" ref={frameRef}>
|
<div className="cbz-reader" ref={frameRef}>
|
||||||
@ -137,7 +201,34 @@ export function CbzReader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cbz-reader" ref={frameRef}>
|
<div className={`cbz-reader${mode === "vertical" ? " cbz-reader-vertical" : ""}`} ref={frameRef}>
|
||||||
|
{mode === "vertical" ? (
|
||||||
|
<div className="reader-vertical-stack">
|
||||||
|
{pages.pages.map((item) => (
|
||||||
|
<figure className="comic-page-frame comic-page-frame-vertical" data-reader-page={item.page} key={`${item.page}-${retryAttempt}`}>
|
||||||
|
{!imageSizes[item.page] && (
|
||||||
|
<div className="reader-fallback">
|
||||||
|
<span>Page {item.page}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={api.cbzPageUrl(bookId, item.page)}
|
||||||
|
alt={item.name}
|
||||||
|
loading="lazy"
|
||||||
|
style={verticalImageStyle(item.page)}
|
||||||
|
hidden={!imageSizes[item.page]}
|
||||||
|
onLoad={(event) => {
|
||||||
|
setImageSizes((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.page]: { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||||
{!imageSize && (
|
{!imageSize && (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
@ -156,6 +247,7 @@ export function CbzReader({
|
|||||||
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
|
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
|||||||
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
||||||
import { configurePdfWorker } from "./pdfWorker";
|
import { configurePdfWorker } from "./pdfWorker";
|
||||||
import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout";
|
import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout";
|
||||||
|
import { isPageInRenderWindow, majorityVisiblePage, type ReaderMode } from "./readerScroll";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
|
||||||
configurePdfWorker(pdfjs);
|
configurePdfWorker(pdfjs);
|
||||||
@ -16,6 +17,7 @@ type PdfReaderProps = {
|
|||||||
page: number;
|
page: number;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
mode: ReaderMode;
|
||||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
};
|
};
|
||||||
@ -60,9 +62,94 @@ function afterNextPaint() {
|
|||||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
return new Promise<void>((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<HTMLCanvasElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rendered, setRendered] = useState(false);
|
||||||
|
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
|
||||||
|
const [pageError, setPageError] = useState<string>();
|
||||||
|
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 (
|
||||||
|
<div className="pdf-page-frame pdf-page-frame-vertical" data-reader-page={pageNumber}>
|
||||||
|
{(!active || !viewportSize || loading || !rendered || pageError) && (
|
||||||
|
<div className={`reader-fallback${pageError ? " reader-fallback-error" : ""}`}>
|
||||||
|
<span>{pageError ?? `Page ${pageNumber}`}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
|
||||||
const frameRef = useRef<HTMLDivElement>(null);
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const previousModeRef = useRef<ReaderMode>(mode);
|
||||||
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [documentError, setDocumentError] = useState<PdfFailure>();
|
const [documentError, setDocumentError] = useState<PdfFailure>();
|
||||||
@ -82,9 +169,12 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC
|
|||||||
(nextPage: number) => {
|
(nextPage: number) => {
|
||||||
const target = clampReaderPage(nextPage, pages);
|
const target = clampReaderPage(nextPage, pages);
|
||||||
setPageError(undefined);
|
setPageError(undefined);
|
||||||
|
if (mode === "vertical") {
|
||||||
|
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
onPageCommit(target, pages, 1, "immediate");
|
onPageCommit(target, pages, 1, "immediate");
|
||||||
},
|
},
|
||||||
[onPageCommit, pages]
|
[mode, onPageCommit, pages]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,6 +187,49 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC
|
|||||||
});
|
});
|
||||||
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
|
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "vertical") {
|
||||||
|
previousModeRef.current = mode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previousModeRef.current !== "vertical") {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
frameRef.current?.querySelector<HTMLElement>(`[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<HTMLElement>("[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(() => {
|
useEffect(() => {
|
||||||
const frame = frameRef.current;
|
const frame = frameRef.current;
|
||||||
const stage = scrollContainerFor(frame);
|
const stage = scrollContainerFor(frame);
|
||||||
@ -181,6 +314,10 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC
|
|||||||
}, [documentAttempt, url]);
|
}, [documentAttempt, url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode === "vertical") {
|
||||||
|
setPageError(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!documentProxy || !viewportSize) return;
|
if (!documentProxy || !viewportSize) return;
|
||||||
const loadedDocument = documentProxy;
|
const loadedDocument = documentProxy;
|
||||||
const readerViewport = viewportSize;
|
const readerViewport = viewportSize;
|
||||||
@ -289,7 +426,7 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
renderTask?.cancel();
|
renderTask?.cancel();
|
||||||
};
|
};
|
||||||
}, [currentPage, documentProxy, pageAttempt, viewportSize, zoom]);
|
}, [currentPage, documentProxy, mode, pageAttempt, viewportSize, zoom]);
|
||||||
|
|
||||||
if (documentError) {
|
if (documentError) {
|
||||||
return (
|
return (
|
||||||
@ -307,11 +444,27 @@ export function PdfReader({ url, page, backHref, zoom, onPageCommit, onControlsC
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pdf-reader" ref={frameRef}>
|
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
||||||
{loadingDocument || !documentProxy ? (
|
{loadingDocument || !documentProxy ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Ouverture PDF</span>
|
<span>Ouverture PDF</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : mode === "vertical" ? (
|
||||||
|
<div className="reader-vertical-stack">
|
||||||
|
{Array.from({ length: pages }, (_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
return (
|
||||||
|
<PdfPageCanvas
|
||||||
|
key={pageNumber}
|
||||||
|
documentProxy={documentProxy}
|
||||||
|
pageNumber={pageNumber}
|
||||||
|
viewportSize={viewportSize}
|
||||||
|
zoom={zoom}
|
||||||
|
active={isPageInRenderWindow(pageNumber, currentPage, pages)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : pageError ? (
|
) : pageError ? (
|
||||||
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||||
<div className="reader-fallback reader-fallback-error">
|
<div className="reader-fallback reader-fallback-error">
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import {
|
|||||||
exitElementFullscreen,
|
exitElementFullscreen,
|
||||||
isElementFullscreen,
|
isElementFullscreen,
|
||||||
isReaderHeaderActive,
|
isReaderHeaderActive,
|
||||||
readerArrowAction,
|
readerNavigationAction,
|
||||||
shouldHandleReaderArrowKey,
|
shouldHandleReaderNavigationKey,
|
||||||
supportsElementFullscreen
|
supportsElementFullscreen
|
||||||
} from "./readerFullscreen";
|
} from "./readerFullscreen";
|
||||||
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
||||||
|
import type { ReaderMode } from "./readerScroll";
|
||||||
|
|
||||||
export type ReaderControls = {
|
export type ReaderControls = {
|
||||||
canPrevious: boolean;
|
canPrevious: boolean;
|
||||||
@ -28,6 +29,11 @@ export type ReaderZoomControls = {
|
|||||||
onZoomReset: () => void;
|
onZoomReset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReaderModeControls = {
|
||||||
|
mode: ReaderMode;
|
||||||
|
onModeChange: (mode: ReaderMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type ReaderShellProps = {
|
type ReaderShellProps = {
|
||||||
title: string;
|
title: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
@ -35,17 +41,20 @@ type ReaderShellProps = {
|
|||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
controls: ReaderControls;
|
controls: ReaderControls;
|
||||||
zoomControls?: ReaderZoomControls;
|
zoomControls?: ReaderZoomControls;
|
||||||
|
modeControls?: ReaderModeControls;
|
||||||
children: ReactNode;
|
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<HTMLDivElement>(null);
|
const readerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
|
const stageRef = useRef<HTMLElement>(null);
|
||||||
const headerHideTimerRef = useRef<number | undefined>(undefined);
|
const headerHideTimerRef = useRef<number | undefined>(undefined);
|
||||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
||||||
const [headerVisible, setHeaderVisible] = useState(true);
|
const [headerVisible, setHeaderVisible] = useState(true);
|
||||||
const fullscreenActive = nativeFullscreen || fallbackFullscreen;
|
const fullscreenActive = nativeFullscreen || fallbackFullscreen;
|
||||||
|
const readerMode = modeControls?.mode ?? "horizontal";
|
||||||
|
|
||||||
const clearHeaderHideTimer = useCallback(() => {
|
const clearHeaderHideTimer = useCallback(() => {
|
||||||
if (headerHideTimerRef.current === undefined) return;
|
if (headerHideTimerRef.current === undefined) return;
|
||||||
@ -129,9 +138,15 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
setFallbackFullscreen(false);
|
setFallbackFullscreen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shouldHandleReaderArrowKey(event, readerRef.current)) return;
|
const mode = modeControls?.mode ?? "horizontal";
|
||||||
|
if (!shouldHandleReaderNavigationKey(event, readerRef.current, mode)) return;
|
||||||
revealHeader();
|
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) {
|
if (action === "previous" && controls.canPrevious) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
controls.onPrevious();
|
controls.onPrevious();
|
||||||
@ -143,12 +158,12 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [controls, fallbackFullscreen, revealHeader]);
|
}, [controls, fallbackFullscreen, modeControls?.mode, revealHeader]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={readerRef}
|
ref={readerRef}
|
||||||
className={`reader-page reader-mode-horizontal${fallbackFullscreen ? " reader-fullscreen-fallback" : ""}${fullscreenActive ? " reader-fullscreen-active" : ""}${
|
className={`reader-page reader-mode-${readerMode}${fallbackFullscreen ? " reader-fullscreen-fallback" : ""}${fullscreenActive ? " reader-fullscreen-active" : ""}${
|
||||||
fullscreenActive && !headerVisible ? " reader-header-hidden" : ""
|
fullscreenActive && !headerVisible ? " reader-header-hidden" : ""
|
||||||
}`}
|
}`}
|
||||||
onMouseMove={revealHeader}
|
onMouseMove={revealHeader}
|
||||||
@ -180,6 +195,15 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
</button>
|
</button>
|
||||||
<strong className="reader-title">{title}</strong>
|
<strong className="reader-title">{title}</strong>
|
||||||
<span className="reader-position">{controls.positionLabel}</span>
|
<span className="reader-position">{controls.positionLabel}</span>
|
||||||
|
{modeControls && (
|
||||||
|
<label className="reader-mode-control">
|
||||||
|
<span>Mode de lecture</span>
|
||||||
|
<select value={modeControls.mode} onChange={(event) => modeControls.onModeChange(event.currentTarget.value as ReaderMode)}>
|
||||||
|
<option value="horizontal">Page par page</option>
|
||||||
|
<option value="vertical">Défilement vertical</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Precedent
|
Precedent
|
||||||
@ -216,7 +240,7 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<main className="reader-stage">
|
<main className="reader-stage" ref={stageRef}>
|
||||||
<div className="reader-content">{children}</div>
|
<div className="reader-content">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,13 +31,22 @@ type ReaderArrowEvent = {
|
|||||||
metaKey?: boolean;
|
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 (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 === "ArrowLeft") return "previous";
|
||||||
if (event.key === "ArrowRight") return "next";
|
if (event.key === "ArrowRight") return "next";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readerArrowAction(event: ReaderArrowEvent): "previous" | "next" | null {
|
||||||
|
return readerNavigationAction(event, "horizontal");
|
||||||
|
}
|
||||||
|
|
||||||
export function fullscreenElement(documentRef: Document = document) {
|
export function fullscreenElement(documentRef: Document = document) {
|
||||||
return documentRef.fullscreenElement;
|
return documentRef.fullscreenElement;
|
||||||
}
|
}
|
||||||
@ -74,7 +83,11 @@ export function isReaderHeaderActive(header: HTMLElement | null, documentRef: Do
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldHandleReaderArrowKey(event: KeyboardEvent, readerElement: HTMLElement | null) {
|
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 (isReaderInteractiveTarget(event.target)) return false;
|
||||||
if (typeof Node === "undefined" || !readerElement || !(event.target instanceof Node)) return true;
|
if (typeof Node === "undefined" || !readerElement || !(event.target instanceof Node)) return true;
|
||||||
const documentRef = readerElement.ownerDocument;
|
const documentRef = readerElement.ownerDocument;
|
||||||
|
|||||||
@ -15,8 +15,9 @@ import {
|
|||||||
zoomReaderSize
|
zoomReaderSize
|
||||||
} from "./readerLayout";
|
} from "./readerLayout";
|
||||||
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
|
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 { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||||
|
import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from "./readerScroll";
|
||||||
import { readerErrorMessage } from "./ReaderError";
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
describe("reader runtime helpers", () => {
|
describe("reader runtime helpers", () => {
|
||||||
@ -174,6 +175,35 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(readerArrowAction({ key: "ArrowRight", ctrlKey: true })).toBeNull();
|
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", () => {
|
it("detects fullscreen support and active fullscreen element", () => {
|
||||||
const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement;
|
const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement;
|
||||||
const regularElement = {} as HTMLElement;
|
const regularElement = {} as HTMLElement;
|
||||||
|
|||||||
36
apps/web/src/reader/readerScroll.ts
Normal file
36
apps/web/src/reader/readerScroll.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -962,6 +962,24 @@ main.app-main-reader .reader-page {
|
|||||||
white-space: nowrap;
|
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 {
|
.reader-title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -1016,6 +1034,11 @@ main.app-main-reader .reader-page {
|
|||||||
height: max-content;
|
height: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-mode-vertical .reader-content {
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.pdf-reader,
|
.pdf-reader,
|
||||||
.epub-reader,
|
.epub-reader,
|
||||||
.cbz-reader {
|
.cbz-reader {
|
||||||
@ -1025,6 +1048,23 @@ main.app-main-reader .reader-page {
|
|||||||
min-height: 100%;
|
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 {
|
.epub-host {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -1058,6 +1098,13 @@ main.app-main-reader .reader-page {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdf-page-frame-vertical,
|
||||||
|
.comic-page-frame-vertical {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 72vh;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.pdf-page-frame canvas,
|
.pdf-page-frame canvas,
|
||||||
.cbz-reader img {
|
.cbz-reader img {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Reference in New Issue
Block a user