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:
@ -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<HTMLDivElement>(null);
|
||||
const previousModeRef = useRef<ReaderMode>(mode);
|
||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||
const [documentError, setDocumentError] = useState<string>();
|
||||
const [pageError, setPageError] = useState<string>();
|
||||
@ -27,6 +31,7 @@ export function CbzReader({
|
||||
const [retryAttempt, setRetryAttempt] = useState(0);
|
||||
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
||||
const [imageSize, setImageSize] = useState<ReaderSize | null>(null);
|
||||
const [imageSizes, setImageSizes] = useState<Record<number, ReaderSize>>({});
|
||||
|
||||
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<HTMLElement>(`[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<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) {
|
||||
return (
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
@ -137,25 +201,53 @@ export function CbzReader({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||
{!imageSize && (
|
||||
<div className="reader-fallback">
|
||||
<span>Page {currentPage}</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={`${currentPage}-${retryAttempt}`}
|
||||
src={api.cbzPageUrl(bookId, currentPage)}
|
||||
alt={currentName ?? `Page ${currentPage}`}
|
||||
style={imageStyle}
|
||||
hidden={!imageSize}
|
||||
onLoad={(event) => {
|
||||
setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
|
||||
}}
|
||||
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
|
||||
/>
|
||||
</figure>
|
||||
<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}>
|
||||
{!imageSize && (
|
||||
<div className="reader-fallback">
|
||||
<span>Page {currentPage}</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={`${currentPage}-${retryAttempt}`}
|
||||
src={api.cbzPageUrl(bookId, currentPage)}
|
||||
alt={currentName ?? `Page ${currentPage}`}
|
||||
style={imageStyle}
|
||||
hidden={!imageSize}
|
||||
onLoad={(event) => {
|
||||
setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
|
||||
}}
|
||||
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user