Files
ReadaBook/apps/web/src/reader/CbzReader.tsx

254 lines
9.1 KiB
TypeScript

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";
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>();
const [documentAttempt, setDocumentAttempt] = useState(0);
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;
setDocumentError(undefined);
setPageError(undefined);
setPages(null);
api
.cbzPages(bookId)
.then((nextPages) => {
if (!alive) return;
setPages(nextPages);
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
})
.catch((error) => {
if (!alive) return;
setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible.");
});
return () => {
alive = false;
};
}, [bookId, documentAttempt]);
const pageCount = pages?.pageCount ?? 1;
const currentPage = clampReaderPage(page, pageCount);
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");
},
[mode, onPageCommit, pages]
);
useEffect(() => {
setPageError(undefined);
setImageSize(null);
}, [bookId, currentPage, retryAttempt]);
useEffect(() => {
setImageSizes({});
}, [bookId, 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: 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)
});
}, [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}>
<div className="reader-fallback reader-fallback-error">
<span>{documentError}</span>
<button className="ghost-button" onClick={() => setDocumentAttempt((attempt) => attempt + 1)}>
Reessayer
</button>
</div>
</div>
);
}
if (!pages) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback">
<span>Chargement de l'archive</span>
</div>
</div>
);
}
if (pageError) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback reader-fallback-error">
<span>{pageError}</span>
<button className="ghost-button" onClick={() => setRetryAttempt((attempt) => attempt + 1)}>
Reessayer
</button>
</div>
</div>
);
}
return (
<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>
);
}