388 lines
16 KiB
TypeScript
388 lines
16 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";
|
|
|
|
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
|
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
|
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
|
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
|
|
|
|
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 | null>(null);
|
|
const pendingVerticalAnchorRef = useRef(false);
|
|
const verticalTrackingReadyRef = useRef(false);
|
|
const verticalUserScrollRef = useRef(false);
|
|
const verticalAnchorTargetPageRef = useRef<number | null>(null);
|
|
const verticalAnchorFrameRef = useRef<number | null>(null);
|
|
const verticalInitialSyncFrameRef = useRef<number | null>(null);
|
|
const verticalInitialSyncAttemptRef = useRef(0);
|
|
const verticalInitialSyncDoneRef = useRef(false);
|
|
const verticalAnchorAttemptRef = useRef(0);
|
|
const verticalAnchorStableFramesRef = useRef(0);
|
|
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 clearVerticalAnchorFrame = useCallback(() => {
|
|
if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current);
|
|
verticalAnchorFrameRef.current = null;
|
|
}, []);
|
|
const clearVerticalInitialSync = useCallback(() => {
|
|
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
|
|
verticalInitialSyncFrameRef.current = null;
|
|
}, []);
|
|
const commitVisiblePage = useCallback(
|
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
|
if (!pages || !verticalTrackingReadyRef.current) return;
|
|
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
|
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
|
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) {
|
|
if (allowInitialCommit) verticalInitialSyncDoneRef.current = true;
|
|
onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued");
|
|
}
|
|
},
|
|
[currentPage, onPageCommit, pages]
|
|
);
|
|
const scheduleInitialVisibleSync = useCallback(
|
|
(stage: HTMLElement) => {
|
|
clearVerticalInitialSync();
|
|
verticalInitialSyncAttemptRef.current = 0;
|
|
|
|
const sync = () => {
|
|
verticalInitialSyncFrameRef.current = null;
|
|
if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return;
|
|
if (verticalTrackingReadyRef.current && stage.scrollHeight > stage.clientHeight && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) {
|
|
commitVisiblePage(stage, true);
|
|
}
|
|
verticalInitialSyncAttemptRef.current += 1;
|
|
if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return;
|
|
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
|
|
};
|
|
|
|
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
|
|
},
|
|
[clearVerticalInitialSync, commitVisiblePage]
|
|
);
|
|
const stabilizeVerticalAnchor = useCallback(
|
|
(targetPage: number) => {
|
|
clearVerticalAnchorFrame();
|
|
verticalAnchorAttemptRef.current = 0;
|
|
verticalAnchorStableFramesRef.current = 0;
|
|
verticalTrackingReadyRef.current = false;
|
|
|
|
const measure = () => {
|
|
const frame = frameRef.current;
|
|
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
|
const target = frame?.querySelector<HTMLElement>(`[data-reader-page="${targetPage}"]`);
|
|
if (!stage || !target) return;
|
|
|
|
target.scrollIntoView({ block: "start" });
|
|
verticalAnchorFrameRef.current = requestAnimationFrame(() => {
|
|
verticalAnchorFrameRef.current = null;
|
|
const stageRect = stage.getBoundingClientRect();
|
|
const targetRect = target.getBoundingClientRect();
|
|
const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX;
|
|
const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2;
|
|
verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0;
|
|
|
|
if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) {
|
|
verticalTrackingReadyRef.current = true;
|
|
commitVisiblePage(stage, true);
|
|
scheduleInitialVisibleSync(stage);
|
|
return;
|
|
}
|
|
verticalAnchorAttemptRef.current += 1;
|
|
if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return;
|
|
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
|
|
});
|
|
};
|
|
|
|
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
|
|
},
|
|
[clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync]
|
|
);
|
|
|
|
const go = useCallback(
|
|
(nextPage: number) => {
|
|
if (!pages) return;
|
|
const target = clampReaderPage(nextPage, pages.pageCount);
|
|
setPageError(undefined);
|
|
if (mode === "vertical") {
|
|
verticalUserScrollRef.current = false;
|
|
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;
|
|
pendingVerticalAnchorRef.current = false;
|
|
verticalTrackingReadyRef.current = false;
|
|
verticalUserScrollRef.current = false;
|
|
verticalAnchorTargetPageRef.current = null;
|
|
verticalInitialSyncDoneRef.current = false;
|
|
clearVerticalAnchorFrame();
|
|
clearVerticalInitialSync();
|
|
return;
|
|
}
|
|
const enteringVertical = previousModeRef.current !== "vertical";
|
|
if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) {
|
|
pendingVerticalAnchorRef.current = true;
|
|
verticalTrackingReadyRef.current = false;
|
|
verticalUserScrollRef.current = false;
|
|
if (enteringVertical) verticalInitialSyncDoneRef.current = false;
|
|
clearVerticalAnchorFrame();
|
|
clearVerticalInitialSync();
|
|
}
|
|
if (pendingVerticalAnchorRef.current) {
|
|
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
|
|
if (!target) {
|
|
previousModeRef.current = mode;
|
|
return;
|
|
}
|
|
pendingVerticalAnchorRef.current = false;
|
|
verticalAnchorTargetPageRef.current = currentPage;
|
|
stabilizeVerticalAnchor(currentPage);
|
|
}
|
|
previousModeRef.current = mode;
|
|
}, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, mode, pages, stabilizeVerticalAnchor]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "vertical" || !pages) return;
|
|
const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null;
|
|
if (!stage) return;
|
|
let frameId = 0;
|
|
const markUserScroll = () => {
|
|
verticalUserScrollRef.current = true;
|
|
};
|
|
const markUserScrollKey = (event: KeyboardEvent) => {
|
|
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll();
|
|
};
|
|
const keyTarget = typeof window === "undefined" ? null : window;
|
|
const updateVisiblePage = () => {
|
|
frameId = 0;
|
|
commitVisiblePage(stage);
|
|
};
|
|
const onScroll = () => {
|
|
if (frameId) return;
|
|
frameId = requestAnimationFrame(updateVisiblePage);
|
|
};
|
|
stage.addEventListener("wheel", markUserScroll, { passive: true });
|
|
stage.addEventListener("touchmove", markUserScroll, { passive: true });
|
|
stage.addEventListener("pointerdown", markUserScroll, { passive: true });
|
|
keyTarget?.addEventListener("keydown", markUserScrollKey);
|
|
stage.addEventListener("scroll", onScroll, { passive: true });
|
|
scheduleInitialVisibleSync(stage);
|
|
updateVisiblePage();
|
|
return () => {
|
|
if (frameId) cancelAnimationFrame(frameId);
|
|
stage.removeEventListener("wheel", markUserScroll);
|
|
stage.removeEventListener("touchmove", markUserScroll);
|
|
stage.removeEventListener("pointerdown", markUserScroll);
|
|
keyTarget?.removeEventListener("keydown", markUserScrollKey);
|
|
stage.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]);
|
|
|
|
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
|
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
|
|
|
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)}
|
|
onLoad={(event) => {
|
|
const { naturalWidth, naturalHeight } = event.currentTarget;
|
|
setImageSizes((current) => ({
|
|
...current,
|
|
[item.page]: { width: naturalWidth, height: 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>
|
|
);
|
|
}
|