|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import type { ViewerSettings } from '../types';
|
|
|
import type { ChapterData } from '../types';
|
|
|
import { MangaPage } from './MangaPage';
|
|
|
import { ViewerToolbar } from './ViewerToolbar';
|
|
|
import { SettingsPanel } from './SettingsPanel';
|
|
|
|
|
|
interface MangaViewerProps {
|
|
|
chapter: ChapterData;
|
|
|
currentUrl: string;
|
|
|
settings: ViewerSettings;
|
|
|
onSettingsChange: (s: Partial<ViewerSettings>) => void;
|
|
|
onNavigate: (url: string | null) => void;
|
|
|
onGoHome: () => void;
|
|
|
fetchImage: (url: string, referer: string) => Promise<string>;
|
|
|
isNavigating: boolean;
|
|
|
navError: string | null;
|
|
|
needsAttention: boolean;
|
|
|
}
|
|
|
|
|
|
function buildSlots(images: string[], offset: number): Array<string[]> {
|
|
|
const slots: Array<string[]> = [];
|
|
|
let i = 0;
|
|
|
|
|
|
// Pages before offset: single
|
|
|
while (i < offset && i < images.length) {
|
|
|
slots.push([images[i]]);
|
|
|
i++;
|
|
|
}
|
|
|
|
|
|
// Remaining pages: pairs in natural order [Page N, Page N+1]
|
|
|
while (i < images.length) {
|
|
|
if (i + 1 < images.length) {
|
|
|
slots.push([images[i], images[i + 1]]);
|
|
|
i += 2;
|
|
|
} else {
|
|
|
slots.push([images[i]]);
|
|
|
i++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return slots;
|
|
|
}
|
|
|
|
|
|
export function MangaViewer({
|
|
|
chapter,
|
|
|
currentUrl,
|
|
|
settings,
|
|
|
onSettingsChange,
|
|
|
onNavigate,
|
|
|
onGoHome,
|
|
|
fetchImage,
|
|
|
isNavigating,
|
|
|
navError,
|
|
|
needsAttention,
|
|
|
}: MangaViewerProps) {
|
|
|
const [slotIndex, setSlotIndex] = useState(0);
|
|
|
const [showToolbar, setShowToolbar] = useState(true);
|
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
const [dismissNavError, setDismissNavError] = useState(false);
|
|
|
const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null);
|
|
|
const toolbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
useEffect(() => {
|
|
|
setDismissNavError(false);
|
|
|
if (!navError) return;
|
|
|
const t = setTimeout(() => setDismissNavError(true), 6000);
|
|
|
return () => clearTimeout(t);
|
|
|
}, [navError]);
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
const handleFullscreenChange = () => {
|
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
|
};
|
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
|
}, []);
|
|
|
|
|
|
const slots =
|
|
|
settings.mode === 'spread'
|
|
|
? buildSlots(chapter.images, settings.spreadStartOffset)
|
|
|
: chapter.images.map((img) => [img]);
|
|
|
|
|
|
const totalSlots = slots.length;
|
|
|
const currentSlot = slots[slotIndex] ?? [];
|
|
|
|
|
|
// Compute global page numbers for display
|
|
|
const firstPageNum = slotIndex === 0
|
|
|
? (settings.mode === 'spread' ? 0 : 0)
|
|
|
: slots.slice(0, slotIndex).reduce((acc, s) => acc + s.length, 0);
|
|
|
|
|
|
const displayPage = firstPageNum + 1;
|
|
|
const totalPages = chapter.images.length;
|
|
|
|
|
|
// Reset state on new chapter
|
|
|
useEffect(() => {
|
|
|
setSlotIndex(0);
|
|
|
setShowSettings(false);
|
|
|
setBoundaryWarning(null);
|
|
|
}, [currentUrl]);
|
|
|
|
|
|
// Preload all images in the background so page turns are instant
|
|
|
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
|
|
|
}
|
|
|
});
|
|
|
return () => { cancelled = true; };
|
|
|
}, [chapter.images, currentUrl, fetchImage]);
|
|
|
|
|
|
const goNext = useCallback(() => {
|
|
|
if (slotIndex < totalSlots - 1) {
|
|
|
setSlotIndex((i) => i + 1);
|
|
|
setBoundaryWarning(null);
|
|
|
} else if (chapter.next_chapter) {
|
|
|
if (boundaryWarning === 'last') {
|
|
|
onNavigate(chapter.next_chapter);
|
|
|
} else {
|
|
|
setBoundaryWarning('last');
|
|
|
}
|
|
|
}
|
|
|
}, [slotIndex, totalSlots, chapter.next_chapter, boundaryWarning, onNavigate]);
|
|
|
|
|
|
const goPrev = useCallback(() => {
|
|
|
if (slotIndex > 0) {
|
|
|
setSlotIndex((i) => i - 1);
|
|
|
setBoundaryWarning(null);
|
|
|
} else if (chapter.prev_chapter) {
|
|
|
if (boundaryWarning === 'first') {
|
|
|
onNavigate(chapter.prev_chapter);
|
|
|
} else {
|
|
|
setBoundaryWarning('first');
|
|
|
}
|
|
|
}
|
|
|
}, [slotIndex, chapter.prev_chapter, boundaryWarning, onNavigate]);
|
|
|
|
|
|
const toggleFullscreen = useCallback(async () => {
|
|
|
try {
|
|
|
const isFull = await invoke<boolean>('toggle_fullscreen');
|
|
|
setIsFullscreen(isFull);
|
|
|
} catch (e) {
|
|
|
console.error('Failed to toggle fullscreen:', e);
|
|
|
}
|
|
|
}, []);
|
|
|
|
|
|
// Keyboard navigation
|
|
|
useEffect(() => {
|
|
|
const handler = (e: KeyboardEvent) => {
|
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
if (e.code === 'ArrowRight') {
|
|
|
e.preventDefault();
|
|
|
goNext();
|
|
|
} else if (e.code === 'ArrowLeft') {
|
|
|
e.preventDefault();
|
|
|
goPrev();
|
|
|
} else if (e.code === 'KeyF' || e.key === 'f' || e.key === 'F' || e.key === 'ㄹ') {
|
|
|
toggleFullscreen();
|
|
|
} else if (e.code === 'KeyS' || e.key === 's' || e.key === 'S' || e.key === 'ㄴ') {
|
|
|
setShowSettings((v) => !v);
|
|
|
} else if (e.key === 'Escape') {
|
|
|
if (showSettings) setShowSettings(false);
|
|
|
}
|
|
|
};
|
|
|
window.addEventListener('keydown', handler);
|
|
|
return () => window.removeEventListener('keydown', handler);
|
|
|
}, [goNext, goPrev, showSettings, settings.direction, toggleFullscreen]);
|
|
|
|
|
|
// Auto-hide toolbar
|
|
|
const resetToolbarTimer = useCallback(() => {
|
|
|
setShowToolbar(true);
|
|
|
if (toolbarTimer.current) clearTimeout(toolbarTimer.current);
|
|
|
toolbarTimer.current = setTimeout(() => {
|
|
|
if (!showSettings) setShowToolbar(false);
|
|
|
}, 3000);
|
|
|
}, [showSettings]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
resetToolbarTimer();
|
|
|
return () => {
|
|
|
if (toolbarTimer.current) clearTimeout(toolbarTimer.current);
|
|
|
};
|
|
|
}, []);
|
|
|
|
|
|
const bgMap = {
|
|
|
black: '#000000',
|
|
|
white: '#f8f8f8',
|
|
|
gray: '#1a1a1a',
|
|
|
};
|
|
|
|
|
|
// Click zones: left third = prev, right third = next, middle = toggle toolbar
|
|
|
const handleZoneClick = (e: React.MouseEvent) => {
|
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
|
const x = e.clientX - rect.left;
|
|
|
const third = rect.width / 3;
|
|
|
if (x < third) {
|
|
|
goPrev();
|
|
|
} else if (x > third * 2) {
|
|
|
goNext();
|
|
|
} else {
|
|
|
setShowToolbar((v) => !v);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const referer = new URL(currentUrl).origin;
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
ref={containerRef}
|
|
|
className="manga-viewer"
|
|
|
style={{ backgroundColor: bgMap[settings.backgroundColor] }}
|
|
|
onMouseMove={resetToolbarTimer}
|
|
|
onClick={handleZoneClick}
|
|
|
>
|
|
|
{/* Toolbar */}
|
|
|
<ViewerToolbar
|
|
|
visible={showToolbar}
|
|
|
title={chapter.title}
|
|
|
currentPage={displayPage}
|
|
|
totalPages={totalPages}
|
|
|
currentSlot={slotIndex}
|
|
|
totalSlots={totalSlots}
|
|
|
hasPrev={slotIndex > 0 || !!chapter.prev_chapter}
|
|
|
hasNext={slotIndex < totalSlots - 1 || !!chapter.next_chapter}
|
|
|
settings={settings}
|
|
|
isFullscreen={isFullscreen}
|
|
|
onPrevChapter={() => onNavigate(chapter.prev_chapter)}
|
|
|
onNextChapter={() => onNavigate(chapter.next_chapter)}
|
|
|
onToggleSettings={() => setShowSettings((v) => !v)}
|
|
|
onToggleFullscreen={toggleFullscreen}
|
|
|
onGoHome={onGoHome}
|
|
|
onSettingsChange={onSettingsChange}
|
|
|
/>
|
|
|
|
|
|
{/* Page navigation arrows (floating in center) */}
|
|
|
<button
|
|
|
className={`page-nav-btn page-nav-prev ${!(slotIndex > 0 || !!chapter.prev_chapter) ? 'disabled' : ''}`}
|
|
|
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
|
|
disabled={!(slotIndex > 0 || !!chapter.prev_chapter)}
|
|
|
title="이전 페이지 (←)"
|
|
|
style={{ opacity: showToolbar ? 1 : 0, pointerEvents: showToolbar ? 'auto' : 'none' }}
|
|
|
>
|
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
|
<path d="M15 18l-6-6 6-6"/>
|
|
|
</svg>
|
|
|
</button>
|
|
|
<button
|
|
|
className={`page-nav-btn page-nav-next ${!(slotIndex < totalSlots - 1 || !!chapter.next_chapter) ? 'disabled' : ''}`}
|
|
|
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
|
|
disabled={!(slotIndex < totalSlots - 1 || !!chapter.next_chapter)}
|
|
|
title="다음 페이지 (→)"
|
|
|
style={{ opacity: showToolbar ? 1 : 0, pointerEvents: showToolbar ? 'auto' : 'none' }}
|
|
|
>
|
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
|
<path d="M9 18l6-6-6-6"/>
|
|
|
</svg>
|
|
|
</button>
|
|
|
|
|
|
{/* Full-screen Loading Overlay */}
|
|
|
{isNavigating && (
|
|
|
<div className="full-loading-overlay" onClick={(e) => e.stopPropagation()}>
|
|
|
<div className="loading-spinner-large" />
|
|
|
<div className="loading-text">
|
|
|
{needsAttention
|
|
|
? '열린 창에서 사이트 인증(캡차/로그인 등)을 완료해주세요.'
|
|
|
: '챕터를 불러오는 중...'}
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{/* Boundary Warning */}
|
|
|
{boundaryWarning && (
|
|
|
<div className="boundary-warning">
|
|
|
{boundaryWarning === 'first' ? '첫 페이지입니다. 이전 화로 이동하려면 한 번 더 누르세요.' : '마지막 페이지입니다. 다음 화로 이동하려면 한 번 더 누르세요.'}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{/* Error Toast */}
|
|
|
{!isNavigating && navError && !dismissNavError && (
|
|
|
<div className="nav-status-toast" onClick={(e) => e.stopPropagation()}>
|
|
|
<div className="error-banner">
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
|
</svg>
|
|
|
<span>{navError}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Page display */}
|
|
|
<div
|
|
|
className={`viewer-stage ${settings.mode === 'spread' && currentSlot.length === 2 ? 'spread-mode' : 'single-mode'}`}
|
|
|
style={{ direction: settings.direction }}
|
|
|
>
|
|
|
{currentSlot.map((imgUrl, idx) => (
|
|
|
<MangaPage
|
|
|
key={imgUrl + idx}
|
|
|
imageUrl={imgUrl}
|
|
|
referer={referer}
|
|
|
fitMode={settings.fitMode}
|
|
|
allowUpscale={settings.allowUpscale}
|
|
|
fetchImage={fetchImage}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
{/* Click zone overlays (visual hints) */}
|
|
|
<div className="click-zones" aria-hidden>
|
|
|
<div className="zone zone-prev" />
|
|
|
<div className="zone zone-middle" />
|
|
|
<div className="zone zone-next" />
|
|
|
</div>
|
|
|
|
|
|
{/* Page progress bar */}
|
|
|
<div className="progress-bar-container">
|
|
|
<div
|
|
|
className="progress-bar"
|
|
|
style={{ width: `${((slotIndex + 1) / totalSlots) * 100}%` }}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
{/* Settings panel */}
|
|
|
{showSettings && (
|
|
|
<SettingsPanel
|
|
|
settings={settings}
|
|
|
totalImages={chapter.images.length}
|
|
|
onChange={onSettingsChange}
|
|
|
onClose={() => setShowSettings(false)}
|
|
|
/>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
}
|