|
|
|
|
@ -19,7 +19,11 @@ interface MangaViewerProps {
|
|
|
|
|
needsAttention: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildSlots(images: string[], offset: number): Array<string[]> {
|
|
|
|
|
function buildSlots(
|
|
|
|
|
images: string[],
|
|
|
|
|
offset: number,
|
|
|
|
|
dimMap: Record<string, { width: number; height: number }>
|
|
|
|
|
): Array<string[]> {
|
|
|
|
|
const slots: Array<string[]> = [];
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
|
|
|
|
@ -29,11 +33,27 @@ function buildSlots(images: string[], offset: number): Array<string[]> {
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remaining pages: pairs in natural order [Page N, Page N+1]
|
|
|
|
|
// Remaining pages: pairs in natural order [Page N, Page N+1], unless wide
|
|
|
|
|
while (i < images.length) {
|
|
|
|
|
if (i + 1 < images.length) {
|
|
|
|
|
slots.push([images[i], images[i + 1]]);
|
|
|
|
|
i += 2;
|
|
|
|
|
const dim = dimMap[images[i]];
|
|
|
|
|
const isWide = dim && dim.width > dim.height * 1.1;
|
|
|
|
|
|
|
|
|
|
if (isWide) {
|
|
|
|
|
// Full width / landscape image takes a standalone single slot
|
|
|
|
|
slots.push([images[i]]);
|
|
|
|
|
i++;
|
|
|
|
|
} else if (i + 1 < images.length) {
|
|
|
|
|
const nextDim = dimMap[images[i + 1]];
|
|
|
|
|
const nextIsWide = nextDim && nextDim.width > nextDim.height * 1.1;
|
|
|
|
|
|
|
|
|
|
if (nextIsWide) {
|
|
|
|
|
// Current image is normal portrait, but next is wide -> current stands alone
|
|
|
|
|
slots.push([images[i]]);
|
|
|
|
|
i++;
|
|
|
|
|
} else {
|
|
|
|
|
slots.push([images[i], images[i + 1]]);
|
|
|
|
|
i += 2;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
slots.push([images[i]]);
|
|
|
|
|
i++;
|
|
|
|
|
@ -61,6 +81,7 @@ export function MangaViewer({
|
|
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
|
const [dismissNavError, setDismissNavError] = useState(false);
|
|
|
|
|
const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null);
|
|
|
|
|
const [dimMap, setDimMap] = useState<Record<string, { width: number; height: number }>>({});
|
|
|
|
|
const toolbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
@ -82,7 +103,7 @@ export function MangaViewer({
|
|
|
|
|
|
|
|
|
|
const slots =
|
|
|
|
|
settings.mode === 'spread'
|
|
|
|
|
? buildSlots(chapter.images, settings.spreadStartOffset)
|
|
|
|
|
? buildSlots(chapter.images, settings.spreadStartOffset, dimMap)
|
|
|
|
|
: chapter.images.map((img) => [img]);
|
|
|
|
|
|
|
|
|
|
const totalSlots = slots.length;
|
|
|
|
|
@ -103,17 +124,46 @@ export function MangaViewer({
|
|
|
|
|
setBoundaryWarning(null);
|
|
|
|
|
}, [currentUrl]);
|
|
|
|
|
|
|
|
|
|
// Preload all images in the background so page turns are instant
|
|
|
|
|
// Save/Update reading history with actual chapter title
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chapter.title) return;
|
|
|
|
|
try {
|
|
|
|
|
const saved = JSON.parse(localStorage.getItem('mana-viewer-history') || '[]');
|
|
|
|
|
const filtered = saved.filter((h: any) => h.url !== currentUrl);
|
|
|
|
|
const newEntry = {
|
|
|
|
|
url: currentUrl,
|
|
|
|
|
title: chapter.title,
|
|
|
|
|
chapterTitle: chapter.title,
|
|
|
|
|
seriesUrl: chapter.series_url,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
filtered.unshift(newEntry);
|
|
|
|
|
localStorage.setItem('mana-viewer-history', JSON.stringify(filtered.slice(0, 30)));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to update history', e);
|
|
|
|
|
}
|
|
|
|
|
}, [chapter.title, chapter.series_url, currentUrl]);
|
|
|
|
|
|
|
|
|
|
// Preload all images in the background & measure dimensions for wide page detection
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chapter.images.length) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const ref = new URL(currentUrl).origin;
|
|
|
|
|
// Fire off all fetches concurrently; fetchImage caches results
|
|
|
|
|
// so subsequent MangaPage renders will get instant cache hits.
|
|
|
|
|
chapter.images.forEach((imgUrl) => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
fetchImage(imgUrl, ref).catch(() => {}); // swallow errors – page will retry on its own
|
|
|
|
|
}
|
|
|
|
|
fetchImage(imgUrl, ref)
|
|
|
|
|
.then((dataUri) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.src = dataUri;
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setDimMap((prev) => {
|
|
|
|
|
if (prev[imgUrl]) return prev;
|
|
|
|
|
return { ...prev, [imgUrl]: { width: img.naturalWidth, height: img.naturalHeight } };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
});
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
}, [chapter.images, currentUrl, fetchImage]);
|
|
|
|
|
@ -157,10 +207,10 @@ export function MangaViewer({
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
|
|
if (e.code === 'ArrowRight') {
|
|
|
|
|
if (e.code === 'ArrowRight' || e.code === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
goNext();
|
|
|
|
|
} else if (e.code === 'ArrowLeft') {
|
|
|
|
|
} else if (e.code === 'ArrowLeft' || e.code === 'ArrowUp') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
goPrev();
|
|
|
|
|
} else if (e.code === 'KeyF' || e.key === 'f' || e.key === 'F' || e.key === 'ㄹ') {
|
|
|
|
|
|