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,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<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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previousModeRef = useRef<ReaderMode>(mode);
|
||||
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [documentError, setDocumentError] = useState<PdfFailure>();
|
||||
@ -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<HTMLElement>(`[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<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(() => {
|
||||
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 (
|
||||
<div className="pdf-reader" ref={frameRef}>
|
||||
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
||||
{loadingDocument || !documentProxy ? (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture PDF</span>
|
||||
</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 ? (
|
||||
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||
<div className="reader-fallback reader-fallback-error">
|
||||
|
||||
Reference in New Issue
Block a user